mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-09 03:04:31 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf0b2bcce8 | |||
| 3832020d50 | |||
| ee9b51ff8a | |||
| ed8df2d58f | |||
| f66010e6f9 | |||
| d5c4700d32 | |||
| 969ecfc4ca | |||
| 8f862997eb | |||
| b20075e3dc | |||
| eb3b5aae51 | |||
| af6b6ab6f1 | |||
| 5a1668c753 | |||
| 820d9095d3 | |||
| 2fb41ccbba | |||
| b4666be696 | |||
| 4688ad74ad | |||
| f7ea78d4f0 | |||
| ac112440c3 | |||
| 958b246f05 | |||
| 263f4c3bc9 | |||
| ffddc517e1 | |||
| 5cd0a3e846 | |||
| f4319c4d4f | |||
| 0091a535d5 | |||
| d7a5fb8d66 | |||
| f63054e937 | |||
| efc043abbb |
@@ -178,7 +178,7 @@ jobs:
|
||||
BUILD=zigbuild
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ windows ]]; then
|
||||
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ (freebsd|windows) ]]; then
|
||||
FEATURES="mimalloc"
|
||||
else
|
||||
FEATURES="jemalloc"
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
image_tag:
|
||||
description: 'Tag for this image build'
|
||||
type: string
|
||||
default: 'v2.6.0'
|
||||
default: 'v2.6.2'
|
||||
required: true
|
||||
mark_latest:
|
||||
description: 'Mark this image as latest'
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
version:
|
||||
description: 'Version for this release'
|
||||
type: string
|
||||
default: 'v2.6.0'
|
||||
default: 'v2.6.2'
|
||||
required: true
|
||||
make_latest:
|
||||
description: 'Mark this release as latest'
|
||||
@@ -92,4 +92,4 @@ jobs:
|
||||
files: |
|
||||
./zipped_assets/*
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag_name: ${{ inputs.version }}
|
||||
tag_name: ${{ inputs.version }}
|
||||
|
||||
@@ -58,23 +58,26 @@ jobs:
|
||||
|
||||
- uses: taiki-e/install-action@cargo-hack
|
||||
|
||||
- name: Check Cargo.lock is up to date
|
||||
run: |
|
||||
if ! cargo metadata --format-version 1 --locked --no-deps > /dev/null; then
|
||||
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check formatting
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Check Clippy
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo clippy --all-targets --features full --all -- -D warnings
|
||||
|
||||
- name: Check features
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
|
||||
|
||||
- name: Check Cargo.lock is up to date
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if ! cargo metadata --format-version 1 --locked > /dev/null; then
|
||||
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pre-test:
|
||||
name: Build test
|
||||
runs-on: ubuntu-latest
|
||||
@@ -125,6 +128,10 @@ jobs:
|
||||
|
||||
- name: Setup tools for test
|
||||
run: sudo apt install bridge-utils
|
||||
- name: Setup upnpd for test
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y miniupnpd miniupnpd-iptables iptables
|
||||
|
||||
- name: Setup system for test
|
||||
run: |
|
||||
|
||||
Generated
+536
-139
File diff suppressed because it is too large
Load Diff
@@ -108,9 +108,9 @@ After successful execution, you can check the network status using `easytier-cli
|
||||
```text
|
||||
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
|
||||
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
|
||||
```
|
||||
|
||||
You can test connectivity between nodes:
|
||||
|
||||
+3
-3
@@ -108,9 +108,9 @@ sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享
|
||||
```text
|
||||
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
|
||||
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
|
||||
```
|
||||
|
||||
您可以测试节点之间的连通性:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
id=easytier_magisk
|
||||
name=EasyTier_Magisk
|
||||
version=v2.6.0
|
||||
version=v2.6.2
|
||||
versionCode=1
|
||||
author=EasyTier
|
||||
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
|
||||
|
||||
@@ -12,6 +12,7 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
guarden = "0.1"
|
||||
|
||||
# Axum web framework
|
||||
axum = { version = "0.8.4", features = ["macros"] }
|
||||
|
||||
@@ -7,15 +7,15 @@ use std::{
|
||||
use anyhow::Context as _;
|
||||
use dashmap::DashMap;
|
||||
use easytier::{
|
||||
common::{
|
||||
config::{ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader},
|
||||
scoped_task::ScopedTask,
|
||||
common::config::{
|
||||
ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader,
|
||||
},
|
||||
defer,
|
||||
instance_manager::NetworkInstanceManager,
|
||||
};
|
||||
use guarden::defer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::any;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use crate::db::{
|
||||
@@ -240,7 +240,7 @@ pub struct HealthChecker {
|
||||
db: Db,
|
||||
instance_mgr: Arc<NetworkInstanceManager>,
|
||||
inst_id_map: DashMap<i32, uuid::Uuid>,
|
||||
node_tasks: DashMap<i32, ScopedTask<()>>,
|
||||
node_tasks: DashMap<i32, AbortOnDropHandle<()>>,
|
||||
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
|
||||
node_cfg: Arc<DashMap<i32, TomlConfigLoader>>,
|
||||
}
|
||||
@@ -465,7 +465,7 @@ impl HealthChecker {
|
||||
}
|
||||
|
||||
// 启动健康检查任务
|
||||
let task = ScopedTask::from(tokio::spawn(Self::node_health_check_task(
|
||||
let task = AbortOnDropHandle::new(tokio::spawn(Self::node_health_check_task(
|
||||
node_id,
|
||||
cfg.get_id(),
|
||||
Arc::clone(&self.instance_mgr),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + TS</title>
|
||||
</head>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "easytier-gui",
|
||||
"type": "module",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build:deps": "pnpm --filter tauri-plugin-vpnservice-api build && pnpm --filter easytier-frontend-lib build",
|
||||
"dev": "pnpm run build:deps && vite",
|
||||
"build": "pnpm run build:deps && vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"lint": "eslint . --ignore-pattern src-tauri",
|
||||
@@ -59,4 +60,4 @@
|
||||
"vue-i18n": "^10.0.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "easytier-gui"
|
||||
version = "2.6.0"
|
||||
version = "2.6.2"
|
||||
description = "EasyTier GUI"
|
||||
authors = ["you"]
|
||||
edition.workspace = true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
import groovy.json.JsonSlurper
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
@@ -14,6 +15,35 @@ val tauriProperties = Properties().apply {
|
||||
}
|
||||
}
|
||||
|
||||
val versionPattern = Regex("""^(\d+)\.(\d+)\.(\d+)$""")
|
||||
|
||||
val tauriVersionName = tauriProperties.getProperty("tauri.android.versionName")?.ifBlank { null } ?: run {
|
||||
val tauriConfFile = file("../../../tauri.conf.json")
|
||||
check(tauriConfFile.exists()) { "Missing tauri.conf.json at ${tauriConfFile.path}" }
|
||||
|
||||
val tauriConf = tauriConfFile.reader(Charsets.UTF_8).use { JsonSlurper().parse(it) as? Map<*, *> }
|
||||
?: error("Failed to parse ${tauriConfFile.path} as a JSON object")
|
||||
tauriConf["version"] as? String
|
||||
?: error("Missing string field \"version\" in ${tauriConfFile.path}")
|
||||
}
|
||||
|
||||
val tauriVersionMatch = versionPattern.matchEntire(tauriVersionName)
|
||||
?: error("Android version must use x.y.z format, but got \"$tauriVersionName\"")
|
||||
|
||||
val tauriVersionCode = if (tauriProperties.getProperty("tauri.android.versionName")?.ifBlank { null } != null) {
|
||||
val versionCodeProp = tauriProperties.getProperty("tauri.android.versionCode")
|
||||
if (versionCodeProp != null) {
|
||||
versionCodeProp.toIntOrNull()
|
||||
?: error("Property \"tauri.android.versionCode\" must be an integer, but got \"$versionCodeProp\"")
|
||||
} else {
|
||||
val (major, minor, patch) = tauriVersionMatch.destructured
|
||||
major.toInt() * 1_000_000 + minor.toInt() * 1_000 + patch.toInt()
|
||||
}
|
||||
} else {
|
||||
val (major, minor, patch) = tauriVersionMatch.destructured
|
||||
major.toInt() * 1_000_000 + minor.toInt() * 1_000 + patch.toInt()
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 34
|
||||
namespace = "com.kkrainbow.easytier"
|
||||
@@ -22,8 +52,8 @@ android {
|
||||
applicationId = "com.kkrainbow.easytier"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
versionCode = tauriVersionCode
|
||||
versionName = tauriVersionName
|
||||
}
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
@@ -82,4 +112,4 @@ dependencies {
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
|
||||
@@ -15,7 +15,9 @@ use easytier::rpc_service::remote_client::{
|
||||
use easytier::web_client::{self, WebClient};
|
||||
use easytier::{
|
||||
common::{
|
||||
config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader},
|
||||
config::{
|
||||
ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader,
|
||||
},
|
||||
log,
|
||||
},
|
||||
instance_manager::NetworkInstanceManager,
|
||||
@@ -118,7 +120,7 @@ async fn run_network_instance(
|
||||
let client_manager = get_client_manager!()?;
|
||||
let toml_config = cfg.gen_config().map_err(|e| e.to_string())?;
|
||||
client_manager
|
||||
.pre_run_network_instance_hook(&app, &toml_config)
|
||||
.pre_run_network_instance_hook(&app, &toml_config, manager::PersistedConfigSource::User)
|
||||
.await?;
|
||||
client_manager
|
||||
.handle_run_network_instance(app.clone(), cfg, save)
|
||||
@@ -207,13 +209,17 @@ async fn update_network_config_state(
|
||||
.map_err(|e: uuid::Error| e.to_string())?;
|
||||
let client_manager = get_client_manager!()?;
|
||||
if !disabled {
|
||||
let cfg = client_manager
|
||||
.handle_get_network_config(app.clone(), instance_id)
|
||||
let (cfg, source) = client_manager
|
||||
.handle_get_network_config_with_source(app.clone(), instance_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let toml_config = cfg.gen_config().map_err(|e| e.to_string())?;
|
||||
client_manager
|
||||
.pre_run_network_instance_hook(&app, &toml_config)
|
||||
.pre_run_network_instance_hook(
|
||||
&app,
|
||||
&toml_config,
|
||||
manager::PersistedConfigSource::from_runtime_source(source),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
client_manager
|
||||
@@ -272,7 +278,7 @@ async fn get_config(app: AppHandle, instance_id: String) -> Result<NetworkConfig
|
||||
#[tauri::command]
|
||||
async fn load_configs(
|
||||
app: AppHandle,
|
||||
configs: Vec<NetworkConfig>,
|
||||
configs: Vec<manager::StoredGuiConfig>,
|
||||
enabled_networks: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
get_client_manager!()?
|
||||
@@ -612,7 +618,11 @@ mod manager {
|
||||
) -> Result<(), String> {
|
||||
let client_manager = get_client_manager!()?;
|
||||
client_manager
|
||||
.pre_run_network_instance_hook(&self.app, cfg)
|
||||
.pre_run_network_instance_hook(
|
||||
&self.app,
|
||||
cfg,
|
||||
PersistedConfigSource::from_runtime_source(cfg.get_network_config_source()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -631,14 +641,87 @@ mod manager {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub(super) enum PersistedConfigSource {
|
||||
User,
|
||||
Webhook,
|
||||
#[serde(other)]
|
||||
#[default]
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl PersistedConfigSource {
|
||||
pub(super) fn from_runtime_source(source: ConfigSource) -> Self {
|
||||
match source {
|
||||
ConfigSource::User => Self::User,
|
||||
ConfigSource::Webhook => Self::Webhook,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_persisted(self, incoming: Self) -> Self {
|
||||
match (self, incoming) {
|
||||
// Older runtimes report missing source as `user`. Keep the stronger persisted
|
||||
// ownership until webhook sync or an explicit user save repairs it.
|
||||
(Self::Webhook, Self::User) | (Self::Legacy, Self::User) => self,
|
||||
(_, next) => next,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_runtime_source(self) -> ConfigSource {
|
||||
match self {
|
||||
Self::User | Self::Legacy => ConfigSource::User,
|
||||
Self::Webhook => ConfigSource::Webhook,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "android"))]
|
||||
fn is_webhook_like(self) -> bool {
|
||||
matches!(self, Self::Webhook)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct GUIConfig(String, pub(crate) NetworkConfig);
|
||||
pub(super) struct GUIConfig {
|
||||
inst_id: String,
|
||||
pub(crate) config: NetworkConfig,
|
||||
source: PersistedConfigSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub(super) struct StoredGuiConfig {
|
||||
config: NetworkConfig,
|
||||
#[serde(default)]
|
||||
source: PersistedConfigSource,
|
||||
}
|
||||
|
||||
impl GUIConfig {
|
||||
fn new(inst_id: String, config: NetworkConfig, source: PersistedConfigSource) -> Self {
|
||||
Self {
|
||||
inst_id,
|
||||
config,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_stored(self) -> StoredGuiConfig {
|
||||
StoredGuiConfig {
|
||||
config: self.config,
|
||||
source: self.source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PersistentConfig<anyhow::Error> for GUIConfig {
|
||||
fn get_network_inst_id(&self) -> &str {
|
||||
&self.0
|
||||
&self.inst_id
|
||||
}
|
||||
fn get_network_config(&self) -> Result<NetworkConfig, anyhow::Error> {
|
||||
Ok(self.1.clone())
|
||||
Ok(self.config.clone())
|
||||
}
|
||||
fn get_network_config_source(&self) -> ConfigSource {
|
||||
self.source.to_runtime_source()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,13 +738,12 @@ mod manager {
|
||||
}
|
||||
|
||||
fn save_configs(&self, app: &AppHandle) -> anyhow::Result<()> {
|
||||
let configs: Result<Vec<String>, _> = self
|
||||
let configs = self
|
||||
.network_configs
|
||||
.iter()
|
||||
.map(|entry| serde_json::to_string(&entry.value().1))
|
||||
.collect();
|
||||
let payload = format!("[{}]", configs?.join(","));
|
||||
app.emit_str("save_configs", payload)?;
|
||||
.map(|entry| entry.value().clone().into_stored())
|
||||
.collect::<Vec<_>>();
|
||||
app.emit("save_configs", configs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -680,8 +762,14 @@ mod manager {
|
||||
app: &AppHandle,
|
||||
inst_id: Uuid,
|
||||
cfg: NetworkConfig,
|
||||
source: PersistedConfigSource,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = GUIConfig(inst_id.to_string(), cfg);
|
||||
let source = self
|
||||
.network_configs
|
||||
.get(&inst_id)
|
||||
.map(|existing| existing.source.merge_persisted(source))
|
||||
.unwrap_or(source);
|
||||
let config = GUIConfig::new(inst_id.to_string(), cfg, source);
|
||||
self.network_configs.insert(inst_id, config);
|
||||
self.save_configs(app)
|
||||
}
|
||||
@@ -693,8 +781,14 @@ mod manager {
|
||||
app: AppHandle,
|
||||
network_inst_id: Uuid,
|
||||
network_config: NetworkConfig,
|
||||
source: ConfigSource,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
self.save_config(&app, network_inst_id, network_config)?;
|
||||
self.save_config(
|
||||
&app,
|
||||
network_inst_id,
|
||||
network_config,
|
||||
PersistedConfigSource::from_runtime_source(source),
|
||||
)?;
|
||||
self.enabled_networks.insert(network_inst_id);
|
||||
self.save_enabled_networks(&app)?;
|
||||
Ok(())
|
||||
@@ -811,17 +905,36 @@ mod manager {
|
||||
.network_configs
|
||||
.iter()
|
||||
.filter(|v| self.storage.enabled_networks.contains(v.key()))
|
||||
.filter(|v| !v.1.no_tun())
|
||||
.filter_map(|c| c.1.instance_id().parse::<uuid::Uuid>().ok())
|
||||
.filter(|v| !v.config.no_tun())
|
||||
.filter_map(|c| c.config.instance_id().parse::<uuid::Uuid>().ok())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn get_enabled_instances_with_webhook_like_tun_ids(
|
||||
&self,
|
||||
) -> impl Iterator<Item = uuid::Uuid> + '_ {
|
||||
self.storage
|
||||
.network_configs
|
||||
.iter()
|
||||
.filter(|v| self.storage.enabled_networks.contains(v.key()))
|
||||
.filter(|v| !v.config.no_tun())
|
||||
.filter(|v| v.source.is_webhook_like())
|
||||
.filter_map(|c| c.config.instance_id().parse::<uuid::Uuid>().ok())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub(super) async fn disable_instances_with_tun(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
webhook_only: bool,
|
||||
) -> Result<(), easytier::rpc_service::remote_client::RemoteClientError<anyhow::Error>>
|
||||
{
|
||||
let inst_ids: Vec<uuid::Uuid> = self.get_enabled_instances_with_tun_ids().collect();
|
||||
let inst_ids: Vec<uuid::Uuid> = if webhook_only {
|
||||
self.get_enabled_instances_with_webhook_like_tun_ids()
|
||||
.collect()
|
||||
} else {
|
||||
self.get_enabled_instances_with_tun_ids().collect()
|
||||
};
|
||||
for inst_id in inst_ids {
|
||||
self.handle_update_network_state(app.clone(), inst_id, true)
|
||||
.await?;
|
||||
@@ -842,6 +955,7 @@ mod manager {
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
cfg: &easytier::common::config::TomlConfigLoader,
|
||||
source: PersistedConfigSource,
|
||||
) -> Result<(), String> {
|
||||
let instance_id = cfg.get_id();
|
||||
app.emit("pre_run_network_instance", instance_id.to_string())
|
||||
@@ -849,9 +963,24 @@ mod manager {
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
if !cfg.get_flags().no_tun {
|
||||
self.disable_instances_with_tun(app)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
match source {
|
||||
PersistedConfigSource::User | PersistedConfigSource::Legacy => {
|
||||
self.disable_instances_with_tun(app, false)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
PersistedConfigSource::Webhook => {
|
||||
self.disable_instances_with_tun(app, true)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if self.get_enabled_instances_with_tun_ids().next().is_some() {
|
||||
return Err(
|
||||
"Android only supports one active TUN network; user-managed VPN remains active"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.storage
|
||||
@@ -859,6 +988,7 @@ mod manager {
|
||||
app,
|
||||
instance_id,
|
||||
NetworkConfig::new_from_config(cfg).map_err(|e| e.to_string())?,
|
||||
source,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -962,15 +1092,15 @@ mod manager {
|
||||
pub(super) async fn load_configs(
|
||||
&self,
|
||||
app: AppHandle,
|
||||
configs: Vec<NetworkConfig>,
|
||||
configs: Vec<StoredGuiConfig>,
|
||||
enabled_networks: Vec<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
self.storage.network_configs.clear();
|
||||
for cfg in configs {
|
||||
let instance_id = cfg.instance_id();
|
||||
for stored in configs {
|
||||
let instance_id = stored.config.instance_id();
|
||||
self.storage.network_configs.insert(
|
||||
instance_id.parse()?,
|
||||
GUIConfig(instance_id.to_string(), cfg),
|
||||
GUIConfig::new(instance_id.to_string(), stored.config, stored.source),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -986,12 +1116,12 @@ mod manager {
|
||||
.storage
|
||||
.network_configs
|
||||
.get(&uuid)
|
||||
.map(|i| i.value().1.clone());
|
||||
let Some(config) = config else {
|
||||
.map(|i| (i.value().config.clone(), i.value().source));
|
||||
let Some((config, source)) = config else {
|
||||
continue;
|
||||
};
|
||||
let toml_config = config.gen_config()?;
|
||||
self.pre_run_network_instance_hook(&app, &toml_config)
|
||||
self.pre_run_network_instance_hook(&app, &toml_config, source)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
client
|
||||
@@ -1001,6 +1131,7 @@ mod manager {
|
||||
inst_id: None,
|
||||
config: Some(config),
|
||||
overwrite: false,
|
||||
source: source.to_runtime_source().to_rpc(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -1032,6 +1163,44 @@ mod manager {
|
||||
&self.storage
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PersistedConfigSource, StoredGuiConfig};
|
||||
use easytier::proto::api::manage::NetworkConfig;
|
||||
|
||||
#[test]
|
||||
fn stored_gui_config_defaults_missing_source_to_legacy() {
|
||||
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
|
||||
"config": NetworkConfig::default(),
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(stored.source, PersistedConfigSource::Legacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_source_merge_keeps_legacy_and_webhook_over_ambiguous_user() {
|
||||
assert_eq!(
|
||||
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::User),
|
||||
PersistedConfigSource::Legacy
|
||||
);
|
||||
assert_eq!(
|
||||
PersistedConfigSource::Webhook.merge_persisted(PersistedConfigSource::User),
|
||||
PersistedConfigSource::Webhook
|
||||
);
|
||||
assert_eq!(
|
||||
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Webhook),
|
||||
PersistedConfigSource::Webhook
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_webhook_configs_are_webhook_like() {
|
||||
assert!(!PersistedConfigSource::Legacy.is_webhook_like());
|
||||
assert!(!PersistedConfigSource::User.is_webhook_like());
|
||||
assert!(PersistedConfigSource::Webhook.is_webhook_like());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"createUpdaterArtifacts": false
|
||||
},
|
||||
"productName": "easytier-gui",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.2",
|
||||
"identifier": "com.kkrainbow.easytier",
|
||||
"plugins": {
|
||||
"shell": {
|
||||
@@ -36,4 +36,4 @@
|
||||
"csp": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist
|
||||
type NetworkConfig = NetworkTypes.NetworkConfig
|
||||
type ValidateConfigResponse = Api.ValidateConfigResponse
|
||||
type ListNetworkInstanceIdResponse = Api.ListNetworkInstanceIdResponse
|
||||
type ConfigSource = 'user' | 'webhook' | 'legacy'
|
||||
interface ServiceOptions {
|
||||
config_dir: string
|
||||
rpc_portal: string
|
||||
@@ -16,6 +17,39 @@ interface ServiceOptions {
|
||||
|
||||
export type ServiceStatus = "Running" | "Stopped" | "NotInstalled"
|
||||
|
||||
interface StoredGuiConfig {
|
||||
config: NetworkConfig
|
||||
source: ConfigSource
|
||||
}
|
||||
|
||||
function parseStoredConfigs(raw: string | null): StoredGuiConfig[] {
|
||||
const parsed: unknown = JSON.parse(raw || '[]')
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parsed.flatMap((entry): StoredGuiConfig[] => {
|
||||
if (entry && typeof entry === 'object' && 'config' in entry) {
|
||||
const { config, source } = entry as {
|
||||
config?: NetworkConfig
|
||||
source?: ConfigSource
|
||||
}
|
||||
if (!config) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
config: NetworkTypes.normalizeNetworkConfig(config),
|
||||
source: source === 'user' || source === 'webhook' ? source : 'legacy',
|
||||
}]
|
||||
}
|
||||
|
||||
return [{
|
||||
config: NetworkTypes.normalizeNetworkConfig(entry as NetworkConfig),
|
||||
source: 'legacy',
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
export async function parseNetworkConfig(cfg: NetworkConfig) {
|
||||
return invoke<string>('parse_network_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
|
||||
}
|
||||
@@ -71,9 +105,12 @@ export async function getConfig(instanceId: string) {
|
||||
}
|
||||
|
||||
export async function sendConfigs(enabledNetworks: string[]) {
|
||||
const networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
|
||||
const networkList = parseStoredConfigs(localStorage.getItem('networkList'))
|
||||
return await invoke('load_configs', {
|
||||
configs: networkList.map((config) => NetworkTypes.toBackendNetworkConfig(NetworkTypes.normalizeNetworkConfig(config))),
|
||||
configs: networkList.map(({ config, source }) => ({
|
||||
config: NetworkTypes.toBackendNetworkConfig(config),
|
||||
source,
|
||||
})),
|
||||
enabledNetworks
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { type } from "@tauri-apps/plugin-os";
|
||||
import { NetworkTypes } from "easytier-frontend-lib"
|
||||
import { Utils } from "easytier-frontend-lib";
|
||||
|
||||
interface StoredGuiConfig {
|
||||
config: NetworkTypes.NetworkConfig
|
||||
source?: 'user' | 'webhook' | 'legacy'
|
||||
}
|
||||
|
||||
const EVENTS = Object.freeze({
|
||||
SAVE_CONFIGS: 'save_configs',
|
||||
PRE_RUN_NETWORK_INSTANCE: 'pre_run_network_instance',
|
||||
@@ -13,9 +18,15 @@ const EVENTS = Object.freeze({
|
||||
EVENT_LAGGED: 'event_lagged',
|
||||
});
|
||||
|
||||
function onSaveConfigs(event: Event<NetworkTypes.NetworkConfig[]>) {
|
||||
function onSaveConfigs(event: Event<StoredGuiConfig[]>) {
|
||||
console.log(`Received event '${EVENTS.SAVE_CONFIGS}': ${event.payload}`);
|
||||
localStorage.setItem('networkList', JSON.stringify(event.payload.map((config) => NetworkTypes.normalizeNetworkConfig(config))));
|
||||
localStorage.setItem(
|
||||
'networkList',
|
||||
JSON.stringify(event.payload.map(({ config, source }) => ({
|
||||
config: NetworkTypes.normalizeNetworkConfig(config),
|
||||
source: source ?? 'legacy',
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeInstanceIdPayload(payload: unknown): string {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ServiceMode extends WebClientConfig {
|
||||
rpc_portal: string
|
||||
file_log_level: 'off' | 'warn' | 'info' | 'debug' | 'trace'
|
||||
file_log_dir: string
|
||||
installed_core_version?: string
|
||||
}
|
||||
|
||||
export interface RemoteMode {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useToast, useConfirm } from 'primevue'
|
||||
import { loadMode, saveMode, WebClientConfig, type Mode } from '~/composables/mode'
|
||||
import { saveLastNetworkInstanceId, loadLastNetworkInstanceId } from '~/composables/config'
|
||||
import ModeSwitcher from '~/components/ModeSwitcher.vue'
|
||||
import { getServiceStatus } from '~/composables/backend'
|
||||
import { getEasytierVersion, getServiceStatus } from '~/composables/backend'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const confirm = useConfirm()
|
||||
@@ -85,6 +85,20 @@ async function onUninstallService() {
|
||||
});
|
||||
}
|
||||
|
||||
function stripModeMetadata(mode: Mode) {
|
||||
if (mode.mode !== 'service') {
|
||||
return mode
|
||||
}
|
||||
|
||||
const serviceConfig = { ...mode }
|
||||
delete serviceConfig.installed_core_version
|
||||
return serviceConfig
|
||||
}
|
||||
|
||||
function modeConfigChanged(next: Mode) {
|
||||
return JSON.stringify(stripModeMetadata(next)) !== JSON.stringify(stripModeMetadata(currentMode.value))
|
||||
}
|
||||
|
||||
async function onStopService() {
|
||||
isModeSaving.value = true
|
||||
manualDisconnect.value = true
|
||||
@@ -134,13 +148,14 @@ async function initWithMode(mode: Mode) {
|
||||
}
|
||||
url = mode.remote_rpc_address
|
||||
break;
|
||||
case 'service':
|
||||
case 'service': {
|
||||
if (!mode.config_dir || !mode.file_log_dir || !mode.file_log_level || !mode.rpc_portal) {
|
||||
toast.add({ severity: 'error', summary: t('error'), detail: t('mode.service_config_empty'), life: 10000 })
|
||||
return initWithMode({ ...mode, mode: 'normal' });
|
||||
}
|
||||
let serviceStatus = await getServiceStatus()
|
||||
if (serviceStatus === "NotInstalled" || JSON.stringify(mode) !== JSON.stringify(currentMode.value)) {
|
||||
const coreVersion = await getEasytierVersion()
|
||||
if (serviceStatus === "NotInstalled" || modeConfigChanged(mode) || mode.installed_core_version !== coreVersion) {
|
||||
mode.config_server_url = mode.config_server_url || undefined
|
||||
await initService({
|
||||
config_dir: mode.config_dir,
|
||||
@@ -149,6 +164,7 @@ async function initWithMode(mode: Mode) {
|
||||
rpc_portal: mode.rpc_portal,
|
||||
config_server: mode.config_server_url,
|
||||
})
|
||||
mode.installed_core_version = coreVersion
|
||||
serviceStatus = await getServiceStatus()
|
||||
}
|
||||
if (serviceStatus === "Stopped") {
|
||||
@@ -157,6 +173,7 @@ async function initWithMode(mode: Mode) {
|
||||
url = "tcp://" + mode.rpc_portal.replace("0.0.0.0", "127.0.0.1")
|
||||
retrys = 5
|
||||
break;
|
||||
}
|
||||
case 'normal':
|
||||
url = mode.rpc_portal;
|
||||
break;
|
||||
|
||||
@@ -33,6 +33,7 @@ const host = process.env.TAURI_DEV_HOST
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
base: './',
|
||||
resolve: {
|
||||
alias: {
|
||||
'~/': `${path.resolve(__dirname, 'src')}/`,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
[package]
|
||||
name = "easytier-web"
|
||||
version = "2.6.0"
|
||||
version = "2.6.2"
|
||||
edition.workspace = true
|
||||
description = "Config server for easytier. easytier-core gets config from this and web frontend use it as restful api server."
|
||||
|
||||
[dependencies]
|
||||
easytier = { path = "../easytier" }
|
||||
easytier = { path = "../easytier", default-features = false, features = ["websocket"] }
|
||||
tracing = { version = "0.1", features = ["log"] }
|
||||
anyhow = { version = "1.0" }
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
dashmap = "6.1"
|
||||
url = "2.2"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -43,10 +44,11 @@
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vitest": "^2.1.9",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.12",
|
||||
"primevue": "^4.3.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import InputGroup from 'primevue/inputgroup'
|
||||
import InputGroupAddon from 'primevue/inputgroupaddon'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { buildUrlInputValue, getHostInputValue, parseHostInputOnBlur, parseUrlInput } from '../modules/url-input'
|
||||
|
||||
const props = defineProps<{
|
||||
placeholder?: string
|
||||
@@ -32,72 +33,11 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const parseUrl = (val: string | null | undefined) => {
|
||||
const getValidPort = (portStr: string, proto: string) => {
|
||||
const p = parseInt(portStr)
|
||||
return isNaN(p) ? (props.protos[proto] ?? 11010) : p
|
||||
}
|
||||
const parseByPattern = (input: string) => {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const match = trimmed.match(/^(\w+):\/\/(.*)$/)
|
||||
const proto = match ? match[1] : 'tcp'
|
||||
const rest = match ? match[2] : trimmed
|
||||
const authority = rest.split(/[/?#]/)[0]
|
||||
if (!authority) {
|
||||
return null
|
||||
}
|
||||
const hostAndMaybePort = authority.includes('@') ? authority.slice(authority.lastIndexOf('@') + 1) : authority
|
||||
if (hostAndMaybePort.startsWith('[')) {
|
||||
const ipv6End = hostAndMaybePort.indexOf(']')
|
||||
if (ipv6End > 0) {
|
||||
const host = hostAndMaybePort.slice(0, ipv6End + 1)
|
||||
const remain = hostAndMaybePort.slice(ipv6End + 1)
|
||||
const port = remain.startsWith(':') ? getValidPort(remain.slice(1), proto) : (props.protos[proto] ?? 11010)
|
||||
return { proto, host, port }
|
||||
}
|
||||
}
|
||||
const portMatch = hostAndMaybePort.match(/^(.*):(\d+)$/)
|
||||
const host = portMatch ? portMatch[1] : hostAndMaybePort
|
||||
const port = portMatch ? parseInt(portMatch[2]) : (props.protos[proto] ?? 11010)
|
||||
return { proto, host, port }
|
||||
}
|
||||
|
||||
if (!val) {
|
||||
return { proto: 'tcp', host: '', port: props.protos['tcp'] ?? 11010 }
|
||||
}
|
||||
const parsedByPattern = parseByPattern(val)
|
||||
if (parsedByPattern) {
|
||||
return parsedByPattern
|
||||
}
|
||||
return { proto: 'tcp', host: '', port: 11010 }
|
||||
}
|
||||
|
||||
const internalValue = ref(parseUrl(url.value))
|
||||
const internalValue = ref(parseUrlInput(url.value, props.protos))
|
||||
const defaultHost = '0.0.0.0'
|
||||
|
||||
const buildUrlValue = (value: { proto: string, host: string, port: number }, forceDefaultHost = false) => {
|
||||
const proto = value.proto || 'tcp'
|
||||
const rawHost = (value.host ?? '').trim()
|
||||
const host = rawHost || (forceDefaultHost ? defaultHost : '')
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
let port = value.port
|
||||
if (isNaN(parseInt(port as any))) {
|
||||
port = props.protos[proto] ?? 11010
|
||||
}
|
||||
|
||||
if (props.protos[proto] === 0) {
|
||||
return `${proto}://${host}`
|
||||
}
|
||||
return `${proto}://${host}:${port}`
|
||||
}
|
||||
|
||||
const syncUrlFromInternal = (forceDefaultHost = false) => {
|
||||
const nextUrl = buildUrlValue(internalValue.value, forceDefaultHost)
|
||||
const nextUrl = buildUrlInputValue(internalValue.value, props.protos, forceDefaultHost)
|
||||
if (!nextUrl || nextUrl === url.value) {
|
||||
return
|
||||
}
|
||||
@@ -106,6 +46,10 @@ const syncUrlFromInternal = (forceDefaultHost = false) => {
|
||||
|
||||
const onHostBlur = () => {
|
||||
hostFocused.value = false
|
||||
const parsedHost = parseHostInputOnBlur(internalValue.value.host ?? '', internalValue.value.proto, props.protos)
|
||||
if (parsedHost) {
|
||||
internalValue.value = parsedHost
|
||||
}
|
||||
syncUrlFromInternal(true)
|
||||
}
|
||||
|
||||
@@ -122,12 +66,20 @@ const isNoPortProto = computed(() => {
|
||||
return props.protos[internalValue.value.proto] === 0
|
||||
})
|
||||
|
||||
const hostInputValue = computed({
|
||||
get: () => getHostInputValue(internalValue.value),
|
||||
set: (value: string) => {
|
||||
internalValue.value.host = value
|
||||
internalValue.value.suffix = undefined
|
||||
},
|
||||
})
|
||||
|
||||
// Sync from external
|
||||
watch(() => url.value, (newVal) => {
|
||||
if (hostFocused.value) {
|
||||
return
|
||||
}
|
||||
const parsed = parseUrl(newVal)
|
||||
const parsed = parseUrlInput(newVal, props.protos)
|
||||
const internalHost = internalValue.value.host ?? ''
|
||||
const sameHost = parsed.host === internalHost || (!internalHost.trim() && parsed.host === defaultHost)
|
||||
if (parsed.proto !== internalValue.value.proto ||
|
||||
@@ -139,6 +91,9 @@ watch(() => url.value, (newVal) => {
|
||||
|
||||
// Sync to external
|
||||
watch(internalValue, () => {
|
||||
if (hostFocused.value) {
|
||||
return
|
||||
}
|
||||
syncUrlFromInternal(false)
|
||||
}, { deep: true })
|
||||
|
||||
@@ -164,6 +119,8 @@ const onProtoChange = (newProto: string) => {
|
||||
internalValue.value.port = newDefault
|
||||
}
|
||||
internalValue.value.proto = newProto
|
||||
internalValue.value.suffix = undefined
|
||||
internalValue.value.hasExplicitPort = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -173,13 +130,14 @@ const onProtoChange = (newProto: string) => {
|
||||
<AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown
|
||||
class="max-w-32 proto-autocomplete-in-group" @complete="searchProtos"
|
||||
@update:model-value="onProtoChange" />
|
||||
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="grow"
|
||||
<InputText v-model="hostInputValue" :placeholder="placeholder || '0.0.0.0'" class="grow"
|
||||
@focus="onHostFocus" @blur="onHostBlur" />
|
||||
<template v-if="!isNoPortProto">
|
||||
<InputGroupAddon>
|
||||
<span style="font-weight: bold">:</span>
|
||||
</InputGroupAddon>
|
||||
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="max-w-24"
|
||||
:placeholder="String(protos[internalValue.proto] ?? 11010)"
|
||||
fluid />
|
||||
</template>
|
||||
<slot name="actions"></slot>
|
||||
@@ -202,12 +160,13 @@ const onProtoChange = (newProto: string) => {
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('web.common.address') || 'Address' }}</label>
|
||||
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="w-full"
|
||||
<InputText v-model="hostInputValue" :placeholder="placeholder || '0.0.0.0'" class="w-full"
|
||||
@focus="onHostFocus" @blur="onHostBlur" />
|
||||
</div>
|
||||
<div v-if="!isNoPortProto" class="flex flex-col gap-2">
|
||||
<label>{{ t('port') }}</label>
|
||||
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="w-full" />
|
||||
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="w-full"
|
||||
:placeholder="String(protos[internalValue.proto] ?? 11010)" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildUrlInputValue, getHostInputValue, parseHostInputOnBlur, parseUrlInput, type ProtoPorts } from './url-input'
|
||||
|
||||
const protos: ProtoPorts = {
|
||||
tcp: 11010,
|
||||
udp: 11010,
|
||||
wg: 11011,
|
||||
ws: 11011,
|
||||
wss: 11012,
|
||||
quic: 11012,
|
||||
faketcp: 11013,
|
||||
http: 80,
|
||||
https: 443,
|
||||
txt: 0,
|
||||
srv: 0,
|
||||
}
|
||||
|
||||
function normalizeUrl(input: string, defaultProto = 'tcp') {
|
||||
return buildUrlInputValue(parseUrlInput(input, protos, defaultProto), protos, true)
|
||||
}
|
||||
|
||||
describe('parseUrlInput', () => {
|
||||
it.each([
|
||||
['https://raw.githubusercontent.com/aaa/bb/cc.txt', {
|
||||
proto: 'https',
|
||||
host: 'raw.githubusercontent.com',
|
||||
port: null,
|
||||
suffix: '/aaa/bb/cc.txt',
|
||||
hasExplicitPort: false,
|
||||
}],
|
||||
['https://host:4443/path?x=1#hash', {
|
||||
proto: 'https',
|
||||
host: 'host',
|
||||
port: 4443,
|
||||
suffix: '/path?x=1#hash',
|
||||
hasExplicitPort: true,
|
||||
}],
|
||||
['[::1]:11010/path', {
|
||||
proto: 'tcp',
|
||||
host: '[::1]',
|
||||
port: 11010,
|
||||
suffix: '/path',
|
||||
hasExplicitPort: true,
|
||||
}],
|
||||
[' http://host/path ', {
|
||||
proto: 'http',
|
||||
host: 'host',
|
||||
port: null,
|
||||
suffix: '/path',
|
||||
hasExplicitPort: false,
|
||||
}],
|
||||
])('parses %s', (input, expected) => {
|
||||
expect(parseUrlInput(input, protos)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('parses IPv6 host without an explicit port', () => {
|
||||
expect(parseUrlInput('[::1]', protos)).toEqual({
|
||||
proto: 'tcp',
|
||||
host: '[::1]',
|
||||
port: null,
|
||||
suffix: '',
|
||||
hasExplicitPort: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['host:', 'host'],
|
||||
['host:notaport', 'host'],
|
||||
])('falls back to the default port for invalid port input %s', (input, host) => {
|
||||
expect(parseUrlInput(input, protos)).toEqual({
|
||||
proto: 'tcp',
|
||||
host,
|
||||
port: 11010,
|
||||
suffix: '',
|
||||
hasExplicitPort: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the explicit proto for an input without authority', () => {
|
||||
expect(parseUrlInput('https://', protos)).toEqual({
|
||||
proto: 'https',
|
||||
host: '',
|
||||
port: null,
|
||||
suffix: '',
|
||||
hasExplicitPort: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildUrlInputValue', () => {
|
||||
it.each([
|
||||
['https://host', 'https://host'],
|
||||
['http://host', 'http://host'],
|
||||
['https://host:4443/path', 'https://host:4443/path'],
|
||||
['https://host:443/path', 'https://host:443/path'],
|
||||
['tcp://host', 'tcp://host'],
|
||||
['wss://host', 'wss://host'],
|
||||
['http://host/path?x=1#hash', 'http://host/path?x=1#hash'],
|
||||
['https://host?x=1', 'https://host?x=1'],
|
||||
['https://host#hash', 'https://host#hash'],
|
||||
['txt://example.com/path.txt', 'txt://example.com/path.txt'],
|
||||
['srv://_easytier._tcp.example.com', 'srv://_easytier._tcp.example.com'],
|
||||
['custom://host/path', 'custom://host/path'],
|
||||
])('normalizes %s to %s', (input, expected) => {
|
||||
expect(normalizeUrl(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it('returns null for empty host unless default host is forced', () => {
|
||||
const parsed = parseUrlInput('', protos)
|
||||
|
||||
expect(buildUrlInputValue(parsed, protos, false)).toBeNull()
|
||||
expect(buildUrlInputValue(parsed, protos, true)).toBe('tcp://0.0.0.0:11010')
|
||||
})
|
||||
|
||||
it('does not build a broken URL for a protocol without authority', () => {
|
||||
const parsed = parseUrlInput('https://', protos)
|
||||
|
||||
expect(buildUrlInputValue(parsed, protos, false)).toBeNull()
|
||||
expect(buildUrlInputValue(parsed, protos, true)).toBe('https://0.0.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseHostInputOnBlur', () => {
|
||||
it('infers https for a pasted host:port/path when the current proto is tcp', () => {
|
||||
const parsed = parseHostInputOnBlur('raw.githubusercontent.com:4443/aaa/bb/cc.txt', 'tcp', protos)
|
||||
|
||||
expect(parsed).toEqual({
|
||||
proto: 'https',
|
||||
host: 'raw.githubusercontent.com',
|
||||
port: 4443,
|
||||
suffix: '/aaa/bb/cc.txt',
|
||||
hasExplicitPort: true,
|
||||
})
|
||||
expect(buildUrlInputValue(parsed!, protos, true)).toBe('https://raw.githubusercontent.com:4443/aaa/bb/cc.txt')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['raw.githubusercontent.com/aaa/bb/cc.txt', 'tcp', 'https://raw.githubusercontent.com/aaa/bb/cc.txt'],
|
||||
['raw.githubusercontent.com:4443/aaa/bb/cc.txt', 'https', 'https://raw.githubusercontent.com:4443/aaa/bb/cc.txt'],
|
||||
['https://raw.githubusercontent.com:4443/aaa/bb/cc.txt', 'tcp', 'https://raw.githubusercontent.com:4443/aaa/bb/cc.txt'],
|
||||
[' https://raw.githubusercontent.com/aaa/bb/cc.txt ', 'tcp', 'https://raw.githubusercontent.com/aaa/bb/cc.txt'],
|
||||
])('normalizes pasted host input %s with current proto %s', (input, currentProto, expected) => {
|
||||
const parsed = parseHostInputOnBlur(input, currentProto, protos)
|
||||
|
||||
expect(buildUrlInputValue(parsed!, protos, true)).toBe(expected)
|
||||
})
|
||||
|
||||
it('keeps ordinary host:port input on the current tcp protocol', () => {
|
||||
const parsed = parseHostInputOnBlur('example.com:11010', 'tcp', protos)
|
||||
|
||||
expect(buildUrlInputValue(parsed!, protos, true)).toBe('tcp://example.com:11010')
|
||||
})
|
||||
|
||||
it('returns null for a simple host without port or suffix', () => {
|
||||
expect(parseHostInputOnBlur('example.com', 'tcp', protos)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHostInputValue', () => {
|
||||
it('shows host and suffix while keeping the port in the port field', () => {
|
||||
const parsed = parseUrlInput('https://raw.githubusercontent.com:4443/aaa/bb/cc.txt', protos)
|
||||
|
||||
expect(getHostInputValue(parsed)).toBe('raw.githubusercontent.com/aaa/bb/cc.txt')
|
||||
})
|
||||
|
||||
it('shows query and hash in the host input suffix', () => {
|
||||
const parsed = parseUrlInput('https://host/path?x=1#hash', protos)
|
||||
|
||||
expect(getHostInputValue(parsed)).toBe('host/path?x=1#hash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('round trip scenarios', () => {
|
||||
it.each([
|
||||
['https://raw.githubusercontent.com/aaa/bb/cc.txt'],
|
||||
['https://raw.githubusercontent.com:4443/aaa/bb/cc.txt'],
|
||||
['http://host/path?x=1#hash'],
|
||||
['tcp://example.com:11010'],
|
||||
['txt://example.com/path.txt'],
|
||||
['srv://_easytier._tcp.example.com'],
|
||||
])('keeps %s stable after parse and build', (input) => {
|
||||
expect(normalizeUrl(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
export interface UrlInputParts {
|
||||
proto: string
|
||||
host: string
|
||||
port: number | null
|
||||
suffix?: string
|
||||
hasExplicitPort?: boolean
|
||||
}
|
||||
|
||||
export type ProtoPorts = Record<string, number>
|
||||
|
||||
const fallbackProto = 'tcp'
|
||||
const fallbackPort = 11010
|
||||
const defaultHost = '0.0.0.0'
|
||||
|
||||
function defaultPortFor(protos: ProtoPorts, proto: string) {
|
||||
return protos[proto] ?? fallbackPort
|
||||
}
|
||||
|
||||
function getValidPort(portStr: string, protos: ProtoPorts, proto: string) {
|
||||
const p = parseInt(portStr)
|
||||
return isNaN(p) ? defaultPortFor(protos, proto) : p
|
||||
}
|
||||
|
||||
export function parseUrlInput(val: string | null | undefined, protos: ProtoPorts, defaultProto = fallbackProto): UrlInputParts {
|
||||
const parseByPattern = (input: string) => {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^(\w+):\/\/(.*)$/)
|
||||
const proto = match ? match[1] : defaultProto
|
||||
const rest = match ? match[2] : trimmed
|
||||
const suffixStart = rest.search(/[/?#]/)
|
||||
const authority = suffixStart >= 0 ? rest.slice(0, suffixStart) : rest
|
||||
const suffix = suffixStart >= 0 ? rest.slice(suffixStart) : ''
|
||||
if (!authority) {
|
||||
return { proto, host: '', port: null, suffix, hasExplicitPort: false }
|
||||
}
|
||||
|
||||
const hostAndMaybePort = authority.includes('@') ? authority.slice(authority.lastIndexOf('@') + 1) : authority
|
||||
if (hostAndMaybePort.startsWith('[')) {
|
||||
const ipv6End = hostAndMaybePort.indexOf(']')
|
||||
if (ipv6End > 0) {
|
||||
const host = hostAndMaybePort.slice(0, ipv6End + 1)
|
||||
const remain = hostAndMaybePort.slice(ipv6End + 1)
|
||||
const hasExplicitPort = remain.startsWith(':')
|
||||
const port = hasExplicitPort ? getValidPort(remain.slice(1), protos, proto) : null
|
||||
return { proto, host, port, suffix, hasExplicitPort }
|
||||
}
|
||||
}
|
||||
|
||||
const portMatch = hostAndMaybePort.match(/^(.*):(\d+)$/)
|
||||
if (portMatch) {
|
||||
return { proto, host: portMatch[1], port: parseInt(portMatch[2]), suffix, hasExplicitPort: true }
|
||||
}
|
||||
|
||||
const invalidPortMatch = hostAndMaybePort.match(/^([^:]+):[^:]*$/)
|
||||
const host = invalidPortMatch ? invalidPortMatch[1] : hostAndMaybePort
|
||||
const port = invalidPortMatch ? defaultPortFor(protos, proto) : null
|
||||
return { proto, host, port, suffix, hasExplicitPort: false }
|
||||
}
|
||||
|
||||
if (!val) {
|
||||
return { proto: defaultProto, host: '', port: defaultPortFor(protos, defaultProto) }
|
||||
}
|
||||
const parsedByPattern = parseByPattern(val)
|
||||
if (parsedByPattern) {
|
||||
return parsedByPattern
|
||||
}
|
||||
return { proto: defaultProto, host: '', port: defaultPortFor(protos, defaultProto) }
|
||||
}
|
||||
|
||||
export function buildUrlInputValue(value: UrlInputParts, protos: ProtoPorts, forceDefaultHost = false) {
|
||||
const proto = value.proto || fallbackProto
|
||||
const rawHost = (value.host ?? '').trim()
|
||||
const host = rawHost || (forceDefaultHost ? defaultHost : '')
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (protos[proto] === 0 || value.port === null) {
|
||||
return `${proto}://${host}${value.suffix ?? ''}`
|
||||
}
|
||||
|
||||
let port = value.port
|
||||
if (isNaN(parseInt(port as any))) {
|
||||
port = defaultPortFor(protos, proto)
|
||||
}
|
||||
|
||||
return `${proto}://${host}:${port}${value.suffix ?? ''}`
|
||||
}
|
||||
|
||||
export function parseHostInputOnBlur(rawHost: string, currentProto: string, protos: ProtoPorts) {
|
||||
const inferredProto = rawHost.includes('/') && currentProto === fallbackProto ? 'https' : currentProto
|
||||
const parsedHost = parseUrlInput(rawHost, protos, inferredProto)
|
||||
if (parsedHost.host && (parsedHost.proto !== currentProto || parsedHost.hasExplicitPort || parsedHost.suffix)) {
|
||||
return parsedHost
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getHostInputValue(value: UrlInputParts) {
|
||||
return `${value.host ?? ''}${value.suffix ?? ''}`
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:deps": "pnpm --filter easytier-frontend-lib build",
|
||||
"dev": "pnpm run build:deps && vite",
|
||||
"build": "pnpm run build:deps && vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -32,4 +33,4 @@
|
||||
"vite-plugin-singlefile": "^2.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,25 +7,70 @@ use std::{
|
||||
|
||||
use anyhow::Context;
|
||||
use easytier::{
|
||||
common::scoped_task::ScopedTask,
|
||||
common::config::ConfigSource,
|
||||
proto::{
|
||||
api::manage::{
|
||||
NetworkConfig, RunNetworkInstanceRequest, WebClientService,
|
||||
WebClientServiceClientFactory,
|
||||
ConfigSource as RpcConfigSource, NetworkConfig, NetworkMeta, RunNetworkInstanceRequest,
|
||||
WebClientService, WebClientServiceClientFactory,
|
||||
},
|
||||
rpc_impl::bidirect::BidirectRpcManager,
|
||||
rpc_types::{self, controller::BaseController},
|
||||
web::{HeartbeatRequest, HeartbeatResponse, WebServerService, WebServerServiceServer},
|
||||
},
|
||||
rpc_service::remote_client::{ListNetworkProps, Storage as _},
|
||||
rpc_service::remote_client::{ListNetworkProps, PersistentConfig as _, Storage as _},
|
||||
tunnel::Tunnel,
|
||||
};
|
||||
use tokio::sync::{RwLock, broadcast};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use super::storage::{Storage, StorageToken, WeakRefStorage};
|
||||
use crate::FeatureFlags;
|
||||
use crate::webhook::SharedWebhookConfig;
|
||||
|
||||
const LEGACY_NETWORK_CONFIG_SOURCE: &str = "legacy";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PersistedConfigSource {
|
||||
User,
|
||||
Webhook,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl PersistedConfigSource {
|
||||
fn from_db(source: &str) -> Self {
|
||||
match source {
|
||||
"webhook" => Self::Webhook,
|
||||
"user" => Self::User,
|
||||
LEGACY_NETWORK_CONFIG_SOURCE => Self::Legacy,
|
||||
_ => Self::User,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_update_from_runtime(self, runtime_source: ConfigSource) -> bool {
|
||||
match (self, runtime_source) {
|
||||
// Older clients report missing source as `user`, which is not authoritative enough
|
||||
// to downgrade an existing webhook-owned or legacy row.
|
||||
(Self::Webhook | Self::Legacy, ConfigSource::User) => false,
|
||||
_ => self.as_runtime_source() != runtime_source,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_runtime_source(self) -> ConfigSource {
|
||||
match self {
|
||||
Self::User | Self::Legacy => ConfigSource::User,
|
||||
Self::Webhook => ConfigSource::Webhook,
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_run_rpc_source(self) -> Option<RpcConfigSource> {
|
||||
match self {
|
||||
Self::User => Some(RpcConfigSource::User),
|
||||
Self::Webhook => Some(RpcConfigSource::Webhook),
|
||||
Self::Legacy => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Location {
|
||||
pub country: String,
|
||||
@@ -148,7 +193,7 @@ impl SessionRpcService {
|
||||
Ok(serde_json::from_value::<NetworkConfig>(network_config)?)
|
||||
}
|
||||
|
||||
async fn reconcile_managed_network_configs(
|
||||
async fn reconcile_webhook_source_configs(
|
||||
storage: &Storage,
|
||||
user_id: i32,
|
||||
machine_id: uuid::Uuid,
|
||||
@@ -159,9 +204,19 @@ impl SessionRpcService {
|
||||
.list_network_configs((user_id, machine_id), ListNetworkProps::All)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to list existing network configs: {:?}", e))?;
|
||||
let existing_ids = existing_configs
|
||||
let existing_sources = existing_configs
|
||||
.iter()
|
||||
.filter_map(|cfg| uuid::Uuid::parse_str(&cfg.network_instance_id).ok())
|
||||
.filter_map(|cfg| {
|
||||
uuid::Uuid::parse_str(&cfg.network_instance_id)
|
||||
.ok()
|
||||
.map(|inst_id| (inst_id, PersistedConfigSource::from_db(&cfg.source)))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let existing_webhook_ids = existing_sources
|
||||
.iter()
|
||||
.filter_map(|(inst_id, source)| {
|
||||
(*source == PersistedConfigSource::Webhook).then_some(*inst_id)
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut desired_ids = HashSet::with_capacity(desired_configs.len());
|
||||
@@ -169,10 +224,30 @@ impl SessionRpcService {
|
||||
for desired in desired_configs {
|
||||
let inst_id = uuid::Uuid::parse_str(&desired.instance_id).with_context(|| {
|
||||
format!(
|
||||
"invalid desired managed instance id: {}",
|
||||
"invalid desired webhook config instance id: {}",
|
||||
desired.instance_id
|
||||
)
|
||||
})?;
|
||||
match existing_sources.get(&inst_id) {
|
||||
Some(PersistedConfigSource::User) => {
|
||||
tracing::warn!(
|
||||
?user_id,
|
||||
?machine_id,
|
||||
instance_id = %inst_id,
|
||||
"skip webhook config because a user-owned config already exists"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Some(PersistedConfigSource::Legacy) => {
|
||||
tracing::info!(
|
||||
?user_id,
|
||||
?machine_id,
|
||||
instance_id = %inst_id,
|
||||
"adopt legacy config as webhook-owned during reconciliation"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let config = Self::normalize_network_config(desired.network_config, inst_id)?;
|
||||
desired_ids.insert(inst_id);
|
||||
normalized.insert(inst_id, config);
|
||||
@@ -181,18 +256,23 @@ impl SessionRpcService {
|
||||
for (inst_id, config) in normalized {
|
||||
storage
|
||||
.db()
|
||||
.insert_or_update_user_network_config((user_id, machine_id), inst_id, config)
|
||||
.insert_or_update_user_network_config(
|
||||
(user_id, machine_id),
|
||||
inst_id,
|
||||
config,
|
||||
ConfigSource::Webhook,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to persist managed network config {}: {:?}",
|
||||
"failed to persist webhook network config {}: {:?}",
|
||||
inst_id,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let stale_ids = existing_ids
|
||||
let stale_ids = existing_webhook_ids
|
||||
.difference(&desired_ids)
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
@@ -225,7 +305,7 @@ impl SessionRpcService {
|
||||
|
||||
let (
|
||||
user_id,
|
||||
webhook_managed_network_configs,
|
||||
webhook_source_configs,
|
||||
webhook_config_revision,
|
||||
webhook_validated,
|
||||
binding_version,
|
||||
@@ -306,11 +386,11 @@ impl SessionRpcService {
|
||||
if webhook_validated
|
||||
&& data.applied_config_revision.as_deref() != Some(webhook_config_revision.as_str())
|
||||
{
|
||||
Self::reconcile_managed_network_configs(
|
||||
Self::reconcile_webhook_source_configs(
|
||||
&storage,
|
||||
user_id,
|
||||
machine_id,
|
||||
webhook_managed_network_configs,
|
||||
webhook_source_configs,
|
||||
)
|
||||
.await
|
||||
.map_err(rpc_types::error::Error::from)?;
|
||||
@@ -396,7 +476,7 @@ pub struct Session {
|
||||
|
||||
data: SharedSessionData,
|
||||
|
||||
run_network_on_start_task: Option<ScopedTask<()>>,
|
||||
run_network_on_start_task: Option<AbortOnDropHandle<()>>,
|
||||
}
|
||||
|
||||
impl Debug for Session {
|
||||
@@ -438,14 +518,134 @@ impl Session {
|
||||
self.rpc_mgr.run_with_tunnel(tunnel);
|
||||
|
||||
let data = self.data.read().await;
|
||||
self.run_network_on_start_task.replace(
|
||||
tokio::spawn(Self::run_network_on_start(
|
||||
data.heartbeat_waiter(),
|
||||
data.storage.clone(),
|
||||
self.scoped_rpc_client(),
|
||||
))
|
||||
.into(),
|
||||
);
|
||||
self.run_network_on_start_task
|
||||
.replace(AbortOnDropHandle::new(tokio::spawn(
|
||||
Self::run_network_on_start(
|
||||
data.heartbeat_waiter(),
|
||||
data.storage.clone(),
|
||||
self.scoped_rpc_client(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
fn collect_webhook_source_instance_ids(
|
||||
metas: Vec<easytier::proto::api::manage::NetworkMeta>,
|
||||
) -> HashSet<String> {
|
||||
metas
|
||||
.into_iter()
|
||||
.filter_map(|meta| {
|
||||
(RpcConfigSource::try_from(meta.source).ok() == Some(RpcConfigSource::Webhook))
|
||||
.then(|| {
|
||||
meta.inst_id
|
||||
.map(|inst_id| Into::<uuid::Uuid>::into(inst_id).to_string())
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn sync_running_config_sources(
|
||||
db: &crate::db::Db,
|
||||
user_id: i32,
|
||||
machine_id: uuid::Uuid,
|
||||
local_configs: &[crate::db::entity::user_running_network_configs::Model],
|
||||
metas: &[NetworkMeta],
|
||||
) -> anyhow::Result<()> {
|
||||
let local_configs_by_id = local_configs
|
||||
.iter()
|
||||
.map(|cfg| (cfg.network_instance_id.clone(), cfg))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
for meta in metas {
|
||||
let Some(inst_id) = meta.inst_id.as_ref().map(|inst_id| {
|
||||
let inst_id: uuid::Uuid = (*inst_id).into();
|
||||
inst_id
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let inst_id_str = inst_id.to_string();
|
||||
let Some(local_cfg) = local_configs_by_id.get(&inst_id_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(running_source) = ConfigSource::from_rpc(meta.source) else {
|
||||
continue;
|
||||
};
|
||||
let local_source = PersistedConfigSource::from_db(&local_cfg.source);
|
||||
if !local_source.should_update_from_runtime(running_source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.insert_or_update_user_network_config(
|
||||
(user_id, machine_id),
|
||||
inst_id,
|
||||
local_cfg.get_network_config().map_err(|e| {
|
||||
anyhow::anyhow!("failed to decode local network config {}: {:?}", inst_id, e)
|
||||
})?,
|
||||
running_source,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to sync running network config source {}: {:?}",
|
||||
inst_id,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn repair_legacy_running_config_sources(
|
||||
db: &crate::db::Db,
|
||||
user_id: i32,
|
||||
machine_id: uuid::Uuid,
|
||||
local_configs: &[crate::db::entity::user_running_network_configs::Model],
|
||||
) -> anyhow::Result<bool> {
|
||||
let legacy_configs = local_configs
|
||||
.iter()
|
||||
.filter(|cfg| {
|
||||
PersistedConfigSource::from_db(&cfg.source) == PersistedConfigSource::Legacy
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if legacy_configs.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
for local_cfg in legacy_configs {
|
||||
let inst_id =
|
||||
uuid::Uuid::parse_str(&local_cfg.network_instance_id).with_context(|| {
|
||||
format!(
|
||||
"failed to parse legacy network config instance id {}",
|
||||
local_cfg.network_instance_id
|
||||
)
|
||||
})?;
|
||||
|
||||
db.insert_or_update_user_network_config(
|
||||
(user_id, machine_id),
|
||||
inst_id,
|
||||
local_cfg.get_network_config().map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to decode legacy network config {}: {:?}",
|
||||
inst_id,
|
||||
e
|
||||
)
|
||||
})?,
|
||||
ConfigSource::User,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to repair legacy network config source {}: {:?}",
|
||||
inst_id,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn run_network_on_start(
|
||||
@@ -453,8 +653,8 @@ impl Session {
|
||||
storage: WeakRefStorage,
|
||||
rpc_client: SessionRpcClient,
|
||||
) {
|
||||
let mut cleaned_web_managed_instances = false;
|
||||
let mut last_desired_inst_ids: Option<HashSet<String>> = None;
|
||||
let mut cleaned_webhook_source_instances = false;
|
||||
let mut last_desired_webhook_inst_ids: Option<HashSet<String>> = None;
|
||||
loop {
|
||||
heartbeat_waiter = heartbeat_waiter.resubscribe();
|
||||
let req = heartbeat_waiter.recv().await;
|
||||
@@ -510,37 +710,160 @@ impl Session {
|
||||
}
|
||||
};
|
||||
|
||||
let mut local_configs = local_configs;
|
||||
let running_metas = if req.support_config_source {
|
||||
let ret = if running_inst_ids.is_empty() {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
rpc_client
|
||||
.list_network_instance_meta(
|
||||
BaseController::default(),
|
||||
easytier::proto::api::manage::ListNetworkInstanceMetaRequest {
|
||||
inst_ids: running_inst_ids
|
||||
.iter()
|
||||
.filter_map(|inst_id| uuid::Uuid::parse_str(inst_id).ok())
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|resp| resp.metas)
|
||||
};
|
||||
|
||||
match ret {
|
||||
Ok(metas) => {
|
||||
if let Err(e) = Self::sync_running_config_sources(
|
||||
&storage.db,
|
||||
user_id,
|
||||
machine_id.into(),
|
||||
&local_configs,
|
||||
&metas,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
?user_id,
|
||||
?machine_id,
|
||||
%e,
|
||||
"Failed to sync running network config sources"
|
||||
);
|
||||
} else if !metas.is_empty() {
|
||||
local_configs = match storage
|
||||
.db
|
||||
.list_network_configs(
|
||||
(user_id, machine_id.into()),
|
||||
ListNetworkProps::EnabledOnly,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(configs) => configs,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to reload network configs after source sync, error: {:?}",
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
Some(metas)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
?user_id,
|
||||
%e,
|
||||
"Failed to list running network instance metadata"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match Self::repair_legacy_running_config_sources(
|
||||
&storage.db,
|
||||
user_id,
|
||||
machine_id.into(),
|
||||
&local_configs,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
local_configs = match storage
|
||||
.db
|
||||
.list_network_configs(
|
||||
(user_id, machine_id.into()),
|
||||
ListNetworkProps::EnabledOnly,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(configs) => configs,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to reload network configs after legacy source repair, error: {:?}",
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
?user_id,
|
||||
?machine_id,
|
||||
%e,
|
||||
"Failed to repair legacy running network config sources"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut has_failed = false;
|
||||
let should_be_alive_inst_ids = local_configs
|
||||
let should_be_alive_webhook_inst_ids = local_configs
|
||||
.iter()
|
||||
.filter(|cfg| cfg.get_runtime_network_config_source() == ConfigSource::Webhook)
|
||||
.map(|cfg| cfg.network_instance_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let desired_changed = last_desired_inst_ids
|
||||
let desired_changed = last_desired_webhook_inst_ids
|
||||
.as_ref()
|
||||
.is_none_or(|last| last != &should_be_alive_inst_ids);
|
||||
.is_none_or(|last| last != &should_be_alive_webhook_inst_ids);
|
||||
|
||||
if !cleaned_web_managed_instances || desired_changed {
|
||||
let all_local_configs = match storage
|
||||
if !cleaned_webhook_source_instances || desired_changed {
|
||||
let db_webhook_inst_ids = match storage
|
||||
.db
|
||||
.list_network_configs((user_id, machine_id.into()), ListNetworkProps::All)
|
||||
.await
|
||||
{
|
||||
Ok(configs) => configs,
|
||||
Ok(configs) => configs
|
||||
.iter()
|
||||
.filter(|cfg| {
|
||||
cfg.get_runtime_network_config_source() == ConfigSource::Webhook
|
||||
})
|
||||
.map(|cfg| cfg.network_instance_id.clone())
|
||||
.collect::<HashSet<_>>(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list all network configs, error: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let all_inst_ids = all_local_configs
|
||||
.iter()
|
||||
.map(|cfg| cfg.network_instance_id.clone())
|
||||
let running_webhook_inst_ids = if let Some(metas) = running_metas.as_ref() {
|
||||
Self::collect_webhook_source_instance_ids(metas.clone())
|
||||
} else {
|
||||
running_inst_ids
|
||||
.intersection(&db_webhook_inst_ids)
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let should_delete_inst_ids = running_webhook_inst_ids
|
||||
.difference(&should_be_alive_webhook_inst_ids)
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let should_delete_ids = running_inst_ids
|
||||
let should_delete_ids = should_delete_inst_ids
|
||||
.iter()
|
||||
.chain(all_inst_ids.iter())
|
||||
.filter(|inst_id| !should_be_alive_inst_ids.contains(*inst_id))
|
||||
.filter_map(|inst_id| uuid::Uuid::parse_str(inst_id).ok())
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>();
|
||||
@@ -556,7 +879,7 @@ impl Session {
|
||||
.await;
|
||||
tracing::info!(
|
||||
?user_id,
|
||||
"Clean non-web-managed network instances on start: {:?}, user_token: {:?}",
|
||||
"Clean stale webhook-source network instances on start: {:?}, user_token: {:?}",
|
||||
ret,
|
||||
req.user_token
|
||||
);
|
||||
@@ -564,8 +887,8 @@ impl Session {
|
||||
}
|
||||
|
||||
if !has_failed {
|
||||
cleaned_web_managed_instances = true;
|
||||
last_desired_inst_ids = Some(should_be_alive_inst_ids.clone());
|
||||
cleaned_webhook_source_instances = true;
|
||||
last_desired_webhook_inst_ids = Some(should_be_alive_webhook_inst_ids.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +896,16 @@ impl Session {
|
||||
if running_inst_ids.contains(&c.network_instance_id) {
|
||||
continue;
|
||||
}
|
||||
let Some(source) = PersistedConfigSource::from_db(&c.source).auto_run_rpc_source()
|
||||
else {
|
||||
tracing::warn!(
|
||||
?user_id,
|
||||
?machine_id,
|
||||
instance_id = %c.network_instance_id,
|
||||
"skip auto-run for legacy config until source ownership is repaired"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let ret = rpc_client
|
||||
.run_network_instance(
|
||||
BaseController::default(),
|
||||
@@ -582,6 +915,7 @@ impl Session {
|
||||
serde_json::from_str::<NetworkConfig>(&c.network_config).unwrap(),
|
||||
),
|
||||
overwrite: false,
|
||||
source: source as i32,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -596,7 +930,7 @@ impl Session {
|
||||
}
|
||||
|
||||
if !has_failed {
|
||||
last_desired_inst_ids = Some(should_be_alive_inst_ids);
|
||||
last_desired_webhook_inst_ids = Some(should_be_alive_webhook_inst_ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -634,13 +968,17 @@ impl Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use easytier::rpc_service::remote_client::{ListNetworkProps, Storage as _};
|
||||
use easytier::{
|
||||
common::config::ConfigSource,
|
||||
rpc_service::remote_client::{ListNetworkProps, PersistentConfig as _, Storage as _},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{super::storage::Storage, *};
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_managed_network_configs_upserts_and_deletes_exact_set() {
|
||||
async fn reconcile_webhook_source_configs_upserts_and_deletes_exact_set() {
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let user_id = storage
|
||||
.db()
|
||||
@@ -662,6 +1000,7 @@ mod tests {
|
||||
network_name: Some("old-name".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigSource::Webhook,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -674,11 +1013,12 @@ mod tests {
|
||||
network_name: Some("stale".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigSource::Webhook,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SessionRpcService::reconcile_managed_network_configs(
|
||||
SessionRpcService::reconcile_webhook_source_configs(
|
||||
&storage,
|
||||
user_id,
|
||||
machine_id,
|
||||
@@ -729,5 +1069,353 @@ mod tests {
|
||||
updated_keep_config.network_name.as_deref(),
|
||||
Some("updated-name")
|
||||
);
|
||||
assert_eq!(
|
||||
updated_keep.get_network_config_source(),
|
||||
ConfigSource::Webhook
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_webhook_source_configs_keep_user_owned_configs() {
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let user_id = storage
|
||||
.db()
|
||||
.auto_create_user("webhook-user-keep-user")
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
let user_owned_id = uuid::Uuid::new_v4();
|
||||
let webhook_owned_id = uuid::Uuid::new_v4();
|
||||
|
||||
storage
|
||||
.db()
|
||||
.insert_or_update_user_network_config(
|
||||
(user_id, machine_id),
|
||||
user_owned_id,
|
||||
NetworkConfig {
|
||||
network_name: Some("user-owned".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigSource::User,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.db()
|
||||
.insert_or_update_user_network_config(
|
||||
(user_id, machine_id),
|
||||
webhook_owned_id,
|
||||
NetworkConfig {
|
||||
network_name: Some("webhook-owned".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigSource::Webhook,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SessionRpcService::reconcile_webhook_source_configs(
|
||||
&storage,
|
||||
user_id,
|
||||
machine_id,
|
||||
vec![crate::webhook::ManagedNetworkConfig {
|
||||
instance_id: user_owned_id.to_string(),
|
||||
network_config: json!({
|
||||
"instance_id": user_owned_id.to_string(),
|
||||
"network_name": "webhook-tries-to-take-over"
|
||||
}),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_owned = storage
|
||||
.db()
|
||||
.get_network_config((user_id, machine_id), &user_owned_id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(user_owned.get_network_config_source(), ConfigSource::User);
|
||||
let user_owned_cfg: NetworkConfig =
|
||||
serde_json::from_str(&user_owned.network_config).unwrap();
|
||||
assert_eq!(user_owned_cfg.network_name.as_deref(), Some("user-owned"));
|
||||
|
||||
let webhook_owned = storage
|
||||
.db()
|
||||
.get_network_config((user_id, machine_id), &webhook_owned_id.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(webhook_owned.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_webhook_source_configs_adopts_legacy_rows_for_webhook() {
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let user_id = storage
|
||||
.db()
|
||||
.auto_create_user("webhook-user-legacy")
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
let legacy_match_id = uuid::Uuid::new_v4();
|
||||
let legacy_user_id = uuid::Uuid::new_v4();
|
||||
|
||||
crate::db::entity::user_running_network_configs::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
device_id: Set(machine_id.to_string()),
|
||||
network_instance_id: Set(legacy_match_id.to_string()),
|
||||
network_config: Set(serde_json::to_string(&NetworkConfig {
|
||||
network_name: Some("legacy-webhook".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()),
|
||||
source: Set(LEGACY_NETWORK_CONFIG_SOURCE.to_string()),
|
||||
disabled: Set(false),
|
||||
create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(storage.db().orm_db())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
crate::db::entity::user_running_network_configs::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
device_id: Set(machine_id.to_string()),
|
||||
network_instance_id: Set(legacy_user_id.to_string()),
|
||||
network_config: Set(serde_json::to_string(&NetworkConfig {
|
||||
network_name: Some("legacy-user".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()),
|
||||
source: Set(LEGACY_NETWORK_CONFIG_SOURCE.to_string()),
|
||||
disabled: Set(false),
|
||||
create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(storage.db().orm_db())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SessionRpcService::reconcile_webhook_source_configs(
|
||||
&storage,
|
||||
user_id,
|
||||
machine_id,
|
||||
vec![crate::webhook::ManagedNetworkConfig {
|
||||
instance_id: legacy_match_id.to_string(),
|
||||
network_config: json!({
|
||||
"instance_id": legacy_match_id.to_string(),
|
||||
"network_name": "managed-by-webhook"
|
||||
}),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let adopted = storage
|
||||
.db()
|
||||
.get_network_config((user_id, machine_id), &legacy_match_id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(adopted.source, ConfigSource::Webhook.as_str());
|
||||
let adopted_cfg: NetworkConfig = serde_json::from_str(&adopted.network_config).unwrap();
|
||||
assert_eq!(
|
||||
adopted_cfg.network_name.as_deref(),
|
||||
Some("managed-by-webhook")
|
||||
);
|
||||
|
||||
let untouched_legacy = storage
|
||||
.db()
|
||||
.get_network_config((user_id, machine_id), &legacy_user_id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(untouched_legacy.source, LEGACY_NETWORK_CONFIG_SOURCE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_running_config_sources_updates_enabled_config_source_from_runtime() {
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let user_id = storage
|
||||
.db()
|
||||
.auto_create_user("webhook-user-sync-source")
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
let inst_id = uuid::Uuid::new_v4();
|
||||
|
||||
storage
|
||||
.db()
|
||||
.insert_or_update_user_network_config(
|
||||
(user_id, machine_id),
|
||||
inst_id,
|
||||
NetworkConfig {
|
||||
network_name: Some("webhook-owned".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigSource::Webhook,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let local_configs = storage
|
||||
.db()
|
||||
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
|
||||
.await
|
||||
.unwrap();
|
||||
Session::sync_running_config_sources(
|
||||
storage.db(),
|
||||
user_id,
|
||||
machine_id,
|
||||
&local_configs,
|
||||
&[easytier::proto::api::manage::NetworkMeta {
|
||||
inst_id: Some(inst_id.into()),
|
||||
source: RpcConfigSource::User as i32,
|
||||
..Default::default()
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let updated = storage
|
||||
.db()
|
||||
.get_network_config((user_id, machine_id), &inst_id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated.get_network_config_source(), ConfigSource::Webhook);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_running_config_sources_keeps_legacy_rows_when_runtime_source_is_user() {
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let user_id = storage
|
||||
.db()
|
||||
.auto_create_user("webhook-user-sync-legacy")
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
let inst_id = uuid::Uuid::new_v4();
|
||||
|
||||
crate::db::entity::user_running_network_configs::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
device_id: Set(machine_id.to_string()),
|
||||
network_instance_id: Set(inst_id.to_string()),
|
||||
network_config: Set(serde_json::to_string(&NetworkConfig {
|
||||
network_name: Some("legacy".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()),
|
||||
source: Set(LEGACY_NETWORK_CONFIG_SOURCE.to_string()),
|
||||
disabled: Set(false),
|
||||
create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(storage.db().orm_db())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let local_configs = storage
|
||||
.db()
|
||||
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
|
||||
.await
|
||||
.unwrap();
|
||||
Session::sync_running_config_sources(
|
||||
storage.db(),
|
||||
user_id,
|
||||
machine_id,
|
||||
&local_configs,
|
||||
&[easytier::proto::api::manage::NetworkMeta {
|
||||
inst_id: Some(inst_id.into()),
|
||||
source: RpcConfigSource::User as i32,
|
||||
..Default::default()
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let updated = storage
|
||||
.db()
|
||||
.get_network_config((user_id, machine_id), &inst_id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated.source, LEGACY_NETWORK_CONFIG_SOURCE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_legacy_running_config_sources_promotes_remaining_legacy_rows_to_user() {
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let user_id = storage
|
||||
.db()
|
||||
.auto_create_user("webhook-user-repair-legacy")
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
let inst_id = uuid::Uuid::new_v4();
|
||||
|
||||
crate::db::entity::user_running_network_configs::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
device_id: Set(machine_id.to_string()),
|
||||
network_instance_id: Set(inst_id.to_string()),
|
||||
network_config: Set(serde_json::to_string(&NetworkConfig {
|
||||
network_name: Some("legacy".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()),
|
||||
source: Set(LEGACY_NETWORK_CONFIG_SOURCE.to_string()),
|
||||
disabled: Set(false),
|
||||
create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(storage.db().orm_db())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let local_configs = storage
|
||||
.db()
|
||||
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
Session::repair_legacy_running_config_sources(
|
||||
storage.db(),
|
||||
user_id,
|
||||
machine_id,
|
||||
&local_configs,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let updated = storage
|
||||
.db()
|
||||
.get_network_config((user_id, machine_id), &inst_id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated.source, ConfigSource::User.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_configs_are_not_auto_run_until_repaired() {
|
||||
assert_eq!(PersistedConfigSource::Legacy.auto_run_rpc_source(), None);
|
||||
assert_eq!(
|
||||
PersistedConfigSource::Webhook.auto_run_rpc_source(),
|
||||
Some(RpcConfigSource::Webhook)
|
||||
);
|
||||
assert_eq!(
|
||||
PersistedConfigSource::User.auto_run_rpc_source(),
|
||||
Some(RpcConfigSource::User)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||
|
||||
use easytier::{launcher::NetworkConfig, rpc_service::remote_client::PersistentConfig};
|
||||
use easytier::{
|
||||
common::config::ConfigSource, launcher::NetworkConfig,
|
||||
rpc_service::remote_client::PersistentConfig,
|
||||
};
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -12,10 +15,12 @@ pub struct Model {
|
||||
pub user_id: i32,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub device_id: String,
|
||||
#[sea_orm(column_type = "Text", unique)]
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub network_instance_id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub network_config: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub source: String,
|
||||
pub disabled: bool,
|
||||
pub create_time: DateTimeWithTimeZone,
|
||||
pub update_time: DateTimeWithTimeZone,
|
||||
@@ -48,4 +53,7 @@ impl PersistentConfig<DbErr> for Model {
|
||||
fn get_network_config(&self) -> Result<NetworkConfig, DbErr> {
|
||||
serde_json::from_str(&self.network_config).map_err(|e| DbErr::Json(e.to_string()))
|
||||
}
|
||||
fn get_network_config_source(&self) -> ConfigSource {
|
||||
self.source.parse().unwrap_or(ConfigSource::User)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod entity;
|
||||
|
||||
use easytier::{
|
||||
common::config::ConfigSource,
|
||||
launcher::NetworkConfig,
|
||||
rpc_service::remote_client::{ListNetworkProps, Storage},
|
||||
};
|
||||
@@ -149,6 +150,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
|
||||
(user_id, device_id): (UserIdInDb, Uuid),
|
||||
network_inst_id: Uuid,
|
||||
network_config: NetworkConfig,
|
||||
source: ConfigSource,
|
||||
) -> Result<(), DbErr> {
|
||||
let txn = self.orm_db().begin().await?;
|
||||
|
||||
@@ -161,6 +163,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
|
||||
])
|
||||
.update_columns([
|
||||
urnc::Column::NetworkConfig,
|
||||
urnc::Column::Source,
|
||||
urnc::Column::Disabled,
|
||||
urnc::Column::UpdateTime,
|
||||
])
|
||||
@@ -172,6 +175,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
|
||||
network_config: sea_orm::Set(
|
||||
serde_json::to_string(&network_config).map_err(|e| DbErr::Json(e.to_string()))?,
|
||||
),
|
||||
source: sea_orm::Set(source.as_str().to_string()),
|
||||
disabled: sea_orm::Set(false),
|
||||
create_time: sea_orm::Set(chrono::Local::now().fixed_offset()),
|
||||
update_time: sea_orm::Set(chrono::Local::now().fixed_offset()),
|
||||
@@ -277,8 +281,12 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use easytier::{proto::api::manage::NetworkConfig, rpc_service::remote_client::Storage};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter as _};
|
||||
use easytier::{
|
||||
common::config::ConfigSource,
|
||||
proto::api::manage::NetworkConfig,
|
||||
rpc_service::remote_client::{PersistentConfig, Storage},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter as _, Set};
|
||||
|
||||
use crate::db::{Db, ListNetworkProps, entity::user_running_network_configs};
|
||||
|
||||
@@ -294,9 +302,14 @@ mod tests {
|
||||
let inst_id = uuid::Uuid::new_v4();
|
||||
let device_id = uuid::Uuid::new_v4();
|
||||
|
||||
db.insert_or_update_user_network_config((user_id, device_id), inst_id, network_config)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_or_update_user_network_config(
|
||||
(user_id, device_id),
|
||||
inst_id,
|
||||
network_config,
|
||||
ConfigSource::User,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = user_running_network_configs::Entity::find()
|
||||
.filter(user_running_network_configs::Column::UserId.eq(user_id))
|
||||
@@ -306,6 +319,7 @@ mod tests {
|
||||
.unwrap();
|
||||
println!("{:?}", result);
|
||||
assert_eq!(result.network_config, network_config_json);
|
||||
assert_eq!(result.get_network_config_source(), ConfigSource::User);
|
||||
|
||||
// overwrite the config
|
||||
let network_config = NetworkConfig {
|
||||
@@ -313,9 +327,14 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let network_config_json = serde_json::to_string(&network_config).unwrap();
|
||||
db.insert_or_update_user_network_config((user_id, device_id), inst_id, network_config)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_or_update_user_network_config(
|
||||
(user_id, device_id),
|
||||
inst_id,
|
||||
network_config,
|
||||
ConfigSource::Webhook,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result2 = user_running_network_configs::Entity::find()
|
||||
.filter(user_running_network_configs::Column::UserId.eq(user_id))
|
||||
@@ -325,6 +344,11 @@ mod tests {
|
||||
.unwrap();
|
||||
println!("device: {}, {:?}", device_id, result2);
|
||||
assert_eq!(result2.network_config, network_config_json);
|
||||
assert_eq!(result2.get_network_config_source(), ConfigSource::Webhook);
|
||||
assert_eq!(
|
||||
result2.get_runtime_network_config_source(),
|
||||
ConfigSource::Webhook
|
||||
);
|
||||
|
||||
assert_eq!(result.create_time, result2.create_time);
|
||||
assert_ne!(result.update_time, result2.update_time);
|
||||
@@ -348,6 +372,45 @@ mod tests {
|
||||
assert!(result3.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_network_config_defaults_to_user_runtime_source() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_id = 1;
|
||||
let inst_id = uuid::Uuid::new_v4();
|
||||
let device_id = uuid::Uuid::new_v4();
|
||||
|
||||
user_running_network_configs::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
device_id: Set(device_id.to_string()),
|
||||
network_instance_id: Set(inst_id.to_string()),
|
||||
network_config: Set(serde_json::to_string(&NetworkConfig {
|
||||
network_name: Some("legacy".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()),
|
||||
source: Set("legacy".to_string()),
|
||||
disabled: Set(false),
|
||||
create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(db.orm_db())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = user_running_network_configs::Entity::find()
|
||||
.filter(user_running_network_configs::Column::UserId.eq(user_id))
|
||||
.one(db.orm_db())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.get_network_config_source(), ConfigSource::User);
|
||||
assert_eq!(
|
||||
result.get_runtime_network_config_source(),
|
||||
ConfigSource::User
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_network_config_same_instance_id_is_scoped_by_device() {
|
||||
let db = Db::memory_db().await;
|
||||
@@ -363,6 +426,7 @@ mod tests {
|
||||
network_name: Some("cfg-1".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigSource::User,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -373,6 +437,7 @@ mod tests {
|
||||
network_name: Some("cfg-2".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigSource::User,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub struct Migration;
|
||||
|
||||
impl MigrationName for Migration {
|
||||
fn name(&self) -> &str {
|
||||
"m20260421_000003_add_network_config_source"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE TABLE user_running_network_configs_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
network_instance_id TEXT NOT NULL,
|
||||
network_config TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'user',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
create_time TEXT NOT NULL,
|
||||
update_time TEXT NOT NULL,
|
||||
CONSTRAINT fk_user_running_network_configs_user_id_to_users_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO user_running_network_configs_new (
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
network_instance_id,
|
||||
network_config,
|
||||
source,
|
||||
disabled,
|
||||
create_time,
|
||||
update_time
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
network_instance_id,
|
||||
network_config,
|
||||
'legacy',
|
||||
disabled,
|
||||
create_time,
|
||||
update_time
|
||||
FROM user_running_network_configs;
|
||||
|
||||
DROP TABLE user_running_network_configs;
|
||||
ALTER TABLE user_running_network_configs_new RENAME TO user_running_network_configs;
|
||||
|
||||
CREATE INDEX idx_user_running_network_configs_user_id
|
||||
ON user_running_network_configs(user_id);
|
||||
CREATE UNIQUE INDEX idx_user_running_network_configs_scope_inst
|
||||
ON user_running_network_configs(user_id, device_id, network_instance_id);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE TABLE user_running_network_configs_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
network_instance_id TEXT NOT NULL,
|
||||
network_config TEXT NOT NULL,
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
create_time TEXT NOT NULL,
|
||||
update_time TEXT NOT NULL,
|
||||
CONSTRAINT fk_user_running_network_configs_user_id_to_users_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO user_running_network_configs_old (
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
network_instance_id,
|
||||
network_config,
|
||||
disabled,
|
||||
create_time,
|
||||
update_time
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
network_instance_id,
|
||||
network_config,
|
||||
disabled,
|
||||
create_time,
|
||||
update_time
|
||||
FROM user_running_network_configs;
|
||||
|
||||
DROP TABLE user_running_network_configs;
|
||||
ALTER TABLE user_running_network_configs_old RENAME TO user_running_network_configs;
|
||||
|
||||
CREATE INDEX idx_user_running_network_configs_user_id
|
||||
ON user_running_network_configs(user_id);
|
||||
CREATE UNIQUE INDEX idx_user_running_network_configs_scope_inst
|
||||
ON user_running_network_configs(user_id, device_id, network_instance_id);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20241029_000001_init;
|
||||
mod m20260403_000002_scope_network_config_unique;
|
||||
mod m20260421_000003_add_network_config_source;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
|
||||
vec![
|
||||
Box::new(m20241029_000001_init::Migration),
|
||||
Box::new(m20260403_000002_scope_network_config_unique::Migration),
|
||||
Box::new(m20260421_000003_add_network_config_source::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ use axum_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer};
|
||||
use axum_login::{AuthManagerLayerBuilder, AuthUser, AuthzBackend, login_required};
|
||||
use axum_messages::MessagesManagerLayer;
|
||||
use easytier::common::config::{ConfigLoader, TomlConfigLoader};
|
||||
use easytier::common::scoped_task::ScopedTask;
|
||||
use easytier::launcher::NetworkConfig;
|
||||
use easytier::proto::rpc_types;
|
||||
use network::NetworkApi;
|
||||
use sea_orm::DbErr;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use tower_sessions::Expiry;
|
||||
use tower_sessions::cookie::time::Duration;
|
||||
use tower_sessions::cookie::{Key, SameSite};
|
||||
@@ -199,8 +199,8 @@ impl RestfulServer {
|
||||
mut self,
|
||||
) -> Result<
|
||||
(
|
||||
ScopedTask<()>,
|
||||
ScopedTask<tower_sessions::session_store::Result<()>>,
|
||||
AbortOnDropHandle<()>,
|
||||
AbortOnDropHandle<tower_sessions::session_store::Result<()>>,
|
||||
),
|
||||
anyhow::Error,
|
||||
> {
|
||||
@@ -213,13 +213,11 @@ impl RestfulServer {
|
||||
let session_store = SqliteStore::new(self.db.inner());
|
||||
session_store.migrate().await?;
|
||||
|
||||
let delete_task: ScopedTask<tower_sessions::session_store::Result<()>> =
|
||||
tokio::task::spawn(
|
||||
session_store
|
||||
.clone()
|
||||
.continuously_delete_expired(tokio::time::Duration::from_secs(60)),
|
||||
)
|
||||
.into();
|
||||
let delete_task = AbortOnDropHandle::new(tokio::task::spawn(
|
||||
session_store
|
||||
.clone()
|
||||
.continuously_delete_expired(tokio::time::Duration::from_secs(60)),
|
||||
));
|
||||
|
||||
// Generate a cryptographic key to sign the session cookie.
|
||||
let key = Key::generate();
|
||||
@@ -298,10 +296,9 @@ impl RestfulServer {
|
||||
app
|
||||
};
|
||||
|
||||
let serve_task: ScopedTask<()> = tokio::spawn(async move {
|
||||
let serve_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
})
|
||||
.into();
|
||||
}));
|
||||
|
||||
Ok((serve_task, delete_task))
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ use axum::{
|
||||
routing,
|
||||
};
|
||||
use axum_embed::ServeEmbed;
|
||||
use easytier::common::scoped_task::ScopedTask;
|
||||
use rust_embed::RustEmbed;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
/// Embed assets for web dashboard, build frontend first
|
||||
#[derive(RustEmbed, Clone)]
|
||||
@@ -59,7 +59,7 @@ pub fn build_router(api_host: Option<url::Url>) -> Router {
|
||||
pub struct WebServer {
|
||||
bind_addr: SocketAddr,
|
||||
router: Router,
|
||||
serve_task: Option<ScopedTask<()>>,
|
||||
serve_task: Option<AbortOnDropHandle<()>>,
|
||||
}
|
||||
|
||||
impl WebServer {
|
||||
@@ -71,14 +71,13 @@ impl WebServer {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start(self) -> Result<ScopedTask<()>, anyhow::Error> {
|
||||
pub async fn start(self) -> Result<AbortOnDropHandle<()>, anyhow::Error> {
|
||||
let listener = TcpListener::bind(self.bind_addr).await?;
|
||||
let app = self.router;
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
})
|
||||
.into();
|
||||
}));
|
||||
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
+12
-12
@@ -3,7 +3,7 @@ name = "easytier"
|
||||
description = "A full meshed p2p VPN, connecting all your devices in one network with one command."
|
||||
homepage = "https://github.com/EasyTier/EasyTier"
|
||||
repository = "https://github.com/EasyTier/EasyTier"
|
||||
version = "2.6.0"
|
||||
version = "2.6.2"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors = ["kkrainbow"]
|
||||
@@ -50,6 +50,8 @@ time = "0.3"
|
||||
toml = "0.8.12"
|
||||
chrono = { version = "0.4.37", features = ["serde"] }
|
||||
|
||||
guarden = "0.1"
|
||||
|
||||
delegate = "0.13.5"
|
||||
|
||||
itertools = "0.14.0"
|
||||
@@ -62,7 +64,7 @@ futures = { version = "0.3", features = ["bilock", "unstable"] }
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-util = { version = "0.7.9", features = ["codec", "net", "io"] }
|
||||
tokio-util = { version = "0.7.9", features = ["codec", "net", "io", "rt"] }
|
||||
|
||||
async-stream = "0.3.5"
|
||||
async-trait = "0.1.74"
|
||||
@@ -252,6 +254,8 @@ shellexpand = "3.1.1"
|
||||
|
||||
# for fake tcp
|
||||
flume = { version = "0.12", optional = true }
|
||||
igd-next = { version = "0.17.0", features = ["aio_tokio"] }
|
||||
natpmp = "0.5.0"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "freebsd"))'.dependencies]
|
||||
machine-uid = "0.5.3"
|
||||
@@ -272,11 +276,15 @@ windivert = { git = "https://github.com/EasyTier/windivert-rust.git", rev = "adc
|
||||
] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.52.0", features = [
|
||||
windows = { version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_NetworkManagement_IpHelper",
|
||||
"Win32_NetworkManagement_Ndis",
|
||||
"Win32_NetworkManagement_WindowsFirewall",
|
||||
"Win32_System_Com",
|
||||
"Win32_Networking",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Diagnostics",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_Ole",
|
||||
"Win32_System_Variant",
|
||||
"Win32_Networking_WinSock",
|
||||
@@ -285,14 +293,6 @@ windows = { version = "0.52.0", features = [
|
||||
encoding = "0.2"
|
||||
winreg = "0.52"
|
||||
windows-service = "0.7.0"
|
||||
windows-sys = { version = "0.52", features = [
|
||||
"Win32_NetworkManagement_IpHelper",
|
||||
"Win32_NetworkManagement_Ndis",
|
||||
"Win32_Networking_WinSock",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Diagnostics",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
] }
|
||||
winapi = { version = "0.3.9", features = ["impl-default"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
|
||||
@@ -39,6 +39,15 @@ core_clap:
|
||||
ipv6:
|
||||
en: "ipv6 address of this vpn node, can be used together with ipv4 for dual-stack operation"
|
||||
zh-CN: "此VPN节点的IPv6地址,可与IPv4一起使用以进行双栈操作"
|
||||
ipv6_public_addr_provider:
|
||||
en: "share this node's public IPv6 subnet with other peers so they can obtain public IPv6 addresses (Linux only)"
|
||||
zh-CN: "将此节点的公网 IPv6 子网共享给其他节点,使它们也能获得公网 IPv6 地址(仅 Linux 支持)"
|
||||
ipv6_public_addr_auto:
|
||||
en: "auto-obtain a public IPv6 address from a peer that shares its IPv6 subnet"
|
||||
zh-CN: "自动从共享了 IPv6 子网的对等节点获取一个公网 IPv6 地址"
|
||||
ipv6_public_addr_prefix:
|
||||
en: "manually specify the public IPv6 subnet to share, instead of auto-detecting from system routes"
|
||||
zh-CN: "手动指定要共享的公网 IPv6 子网,不自动从系统路由检测"
|
||||
dhcp:
|
||||
en: "automatically determine and set IP address by Easytier, and the IP address starts from 10.0.0.1 by default. Warning, if there is an IP conflict in the network when using DHCP, the IP will be automatically changed."
|
||||
zh-CN: "由Easytier自动确定并设置IP地址,默认从10.0.0.1开始。警告:在使用DHCP时,如果网络中出现IP冲突,IP将自动更改。"
|
||||
@@ -172,6 +181,9 @@ core_clap:
|
||||
disable_sym_hole_punching:
|
||||
en: "if true, disable udp nat hole punching for symmetric nat (NAT4), which is based on birthday attack and may be blocked by ISP."
|
||||
zh-CN: "如果为true,则禁用基于生日攻击的对称NAT (NAT4) UDP 打洞功能,该打洞方式可能会被运营商封锁"
|
||||
disable_upnp:
|
||||
en: "disable runtime UPnP/NAT-PMP port mapping for eligible listeners; automatic port mapping is enabled by default"
|
||||
zh-CN: "禁用符合条件监听器的运行时 UPnP/NAT-PMP 端口映射;自动端口映射默认开启"
|
||||
relay_all_peer_rpc:
|
||||
en: "relay all peer rpc packets, even if the peer is not in the relay network whitelist. this can help peers not in relay network whitelist to establish p2p connection."
|
||||
zh-CN: "转发所有对等节点的RPC数据包,即使对等节点不在转发网络白名单中。这可以帮助白名单外网络中的对等节点建立P2P连接。"
|
||||
|
||||
@@ -4,15 +4,16 @@ use anyhow::Context;
|
||||
use network_interface::NetworkInterfaceConfig;
|
||||
use windows::{
|
||||
Win32::{
|
||||
Foundation::{BOOL, FALSE},
|
||||
Foundation::FALSE,
|
||||
NetworkManagement::WindowsFirewall::{
|
||||
INetFwPolicy2, INetFwRule, NET_FW_ACTION_ALLOW, NET_FW_PROFILE2_DOMAIN,
|
||||
NET_FW_PROFILE2_PRIVATE, NET_FW_PROFILE2_PUBLIC, NET_FW_RULE_DIR_IN,
|
||||
NET_FW_RULE_DIR_OUT,
|
||||
},
|
||||
Networking::WinSock::{
|
||||
IP_UNICAST_IF, IPPROTO_IP, IPPROTO_IPV6, IPV6_UNICAST_IF, SIO_UDP_CONNRESET, SOCKET,
|
||||
SOCKET_ERROR, WSAGetLastError, WSAIoctl, htonl, setsockopt,
|
||||
IP_UNICAST_IF, IPPROTO_IP, IPPROTO_IPV6, IPV6_UNICAST_IF, SIO_UDP_CONNRESET,
|
||||
SO_EXCLUSIVEADDRUSE, SOCKET, SOCKET_ERROR, SOL_SOCKET, WSAGetLastError, WSAIoctl,
|
||||
htonl, setsockopt,
|
||||
},
|
||||
System::Com::{
|
||||
CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
|
||||
@@ -20,7 +21,7 @@ use windows::{
|
||||
System::Ole::{SafeArrayCreateVector, SafeArrayPutElement},
|
||||
System::Variant::{VARENUM, VARIANT, VT_ARRAY, VT_BSTR, VT_VARIANT},
|
||||
},
|
||||
core::BSTR,
|
||||
core::{BOOL, BSTR},
|
||||
};
|
||||
|
||||
pub fn disable_connection_reset<S: AsRawSocket>(socket: &S) -> io::Result<()> {
|
||||
@@ -88,13 +89,7 @@ pub fn find_interface_index(iface_name: &str) -> io::Result<u32> {
|
||||
))
|
||||
}
|
||||
|
||||
pub fn set_ip_unicast_if<S: AsRawSocket>(
|
||||
socket: &S,
|
||||
addr: &SocketAddr,
|
||||
iface: &str,
|
||||
) -> io::Result<()> {
|
||||
let handle = SOCKET(socket.as_raw_socket() as usize);
|
||||
|
||||
pub fn set_ip_unicast_if(socket: SOCKET, addr: &SocketAddr, iface: &str) -> io::Result<()> {
|
||||
let if_index = find_interface_index(iface)?;
|
||||
|
||||
unsafe {
|
||||
@@ -103,12 +98,12 @@ pub fn set_ip_unicast_if<S: AsRawSocket>(
|
||||
SocketAddr::V4(..) => {
|
||||
let if_index = htonl(if_index);
|
||||
let if_index_bytes = if_index.to_ne_bytes();
|
||||
setsockopt(handle, IPPROTO_IP.0, IP_UNICAST_IF, Some(&if_index_bytes))
|
||||
setsockopt(socket, IPPROTO_IP.0, IP_UNICAST_IF, Some(&if_index_bytes))
|
||||
}
|
||||
SocketAddr::V6(..) => {
|
||||
let if_index_bytes = if_index.to_ne_bytes();
|
||||
setsockopt(
|
||||
handle,
|
||||
socket,
|
||||
IPPROTO_IPV6.0,
|
||||
IPV6_UNICAST_IF,
|
||||
Some(&if_index_bytes),
|
||||
@@ -141,8 +136,16 @@ pub fn setup_socket_for_win<S: AsRawSocket>(
|
||||
disable_connection_reset(socket)?;
|
||||
}
|
||||
|
||||
let socket = SOCKET(socket.as_raw_socket() as usize);
|
||||
let optval = 1_i32.to_ne_bytes();
|
||||
unsafe {
|
||||
if setsockopt(socket, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, Some(&optval)) == SOCKET_ERROR {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(iface) = bind_dev {
|
||||
set_ip_unicast_if(socket, bind_addr, iface.as_str())?;
|
||||
set_ip_unicast_if(socket, bind_addr, &iface)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -152,7 +155,7 @@ struct ComInitializer;
|
||||
|
||||
impl ComInitializer {
|
||||
fn new() -> windows::core::Result<Self> {
|
||||
unsafe { CoInitializeEx(None, COINIT_MULTITHREADED)? };
|
||||
unsafe { CoInitializeEx(None, COINIT_MULTITHREADED).ok()? };
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
@@ -354,7 +357,7 @@ fn add_protocol_firewall_rules(
|
||||
(*interface_variant.Anonymous.Anonymous).vt = VARENUM(VT_ARRAY.0 | VT_VARIANT.0);
|
||||
(*interface_variant.Anonymous.Anonymous).Anonymous.parray = interface_array;
|
||||
|
||||
rule.SetInterfaces(interface_variant)?;
|
||||
rule.SetInterfaces(&interface_variant)?;
|
||||
|
||||
// Get rule collection and add new rule
|
||||
let rules = policy.Rules()?;
|
||||
|
||||
@@ -18,9 +18,10 @@ use crate::{
|
||||
instance::dns_server::DEFAULT_ET_DNS_ZONE,
|
||||
proto::{
|
||||
acl::Acl,
|
||||
api::manage::ConfigSource as RpcConfigSource,
|
||||
common::{CompressionAlgoPb, PortForwardConfigPb, SecureModeConfig, SocketType},
|
||||
},
|
||||
tunnel::generate_digest_from_str,
|
||||
tunnel::{IpScheme, TunnelScheme, generate_digest_from_str},
|
||||
};
|
||||
|
||||
use super::env_parser;
|
||||
@@ -69,9 +70,40 @@ pub fn gen_default_flags() -> Flags {
|
||||
quic_listen_port: u32::MAX,
|
||||
need_p2p: false,
|
||||
instance_recv_bps_limit: u64::MAX,
|
||||
disable_upnp: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mapped_listener_allows_implicit_port(url: &url::Url) -> bool {
|
||||
TunnelScheme::try_from(url)
|
||||
.ok()
|
||||
.and_then(|scheme| IpScheme::try_from(scheme).ok())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn validate_mapped_listener_url(url: &url::Url) -> Result<(), anyhow::Error> {
|
||||
if url.port().is_none() && !mapped_listener_allows_implicit_port(url) {
|
||||
anyhow::bail!("mapped listener port is missing: {}", url);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_mapped_listener_urls(
|
||||
mapped_listeners: &[String],
|
||||
) -> Result<Vec<url::Url>, anyhow::Error> {
|
||||
mapped_listeners
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let url: url::Url = s
|
||||
.parse()
|
||||
.with_context(|| format!("mapped listener is not a valid url: {}", s))?;
|
||||
validate_mapped_listener_url(&url)?;
|
||||
Ok(url)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Display, EnumString, VariantArray)]
|
||||
#[strum(ascii_case_insensitive)]
|
||||
pub enum EncryptionAlgorithm {
|
||||
@@ -138,6 +170,15 @@ pub trait ConfigLoader: Send + Sync {
|
||||
fn get_ipv6(&self) -> Option<cidr::Ipv6Inet>;
|
||||
fn set_ipv6(&self, addr: Option<cidr::Ipv6Inet>);
|
||||
|
||||
fn get_ipv6_public_addr_provider(&self) -> bool;
|
||||
fn set_ipv6_public_addr_provider(&self, enabled: bool);
|
||||
|
||||
fn get_ipv6_public_addr_auto(&self) -> bool;
|
||||
fn set_ipv6_public_addr_auto(&self, enabled: bool);
|
||||
|
||||
fn get_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr>;
|
||||
fn set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>);
|
||||
|
||||
fn get_dhcp(&self) -> bool;
|
||||
fn set_dhcp(&self, dhcp: bool);
|
||||
|
||||
@@ -205,6 +246,11 @@ pub trait ConfigLoader: Send + Sync {
|
||||
}
|
||||
fn set_credential_file(&self, _path: Option<std::path::PathBuf>) {}
|
||||
|
||||
fn get_network_config_source(&self) -> ConfigSource {
|
||||
ConfigSource::User
|
||||
}
|
||||
fn set_network_config_source(&self, _source: Option<ConfigSource>) {}
|
||||
|
||||
fn dump(&self) -> String;
|
||||
}
|
||||
|
||||
@@ -224,6 +270,55 @@ pub struct NetworkIdentity {
|
||||
pub network_secret_digest: Option<NetworkSecretDigest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfigSource {
|
||||
#[default]
|
||||
User,
|
||||
Webhook,
|
||||
}
|
||||
|
||||
impl ConfigSource {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Webhook => "webhook",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_rpc(source: i32) -> Option<Self> {
|
||||
match RpcConfigSource::try_from(source).ok() {
|
||||
Some(RpcConfigSource::Webhook) => Some(Self::Webhook),
|
||||
Some(RpcConfigSource::User) => Some(Self::User),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_rpc(self) -> i32 {
|
||||
match self {
|
||||
Self::User => RpcConfigSource::User as i32,
|
||||
Self::Webhook => RpcConfigSource::Webhook as i32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ConfigSource {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"user" => Ok(Self::User),
|
||||
"webhook" => Ok(Self::Webhook),
|
||||
other => Err(format!("unknown network config source: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
struct ConfigSourceConfig {
|
||||
source: ConfigSource,
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Hash)]
|
||||
struct NetworkIdentityWithOnlyDigest {
|
||||
network_name: String,
|
||||
@@ -433,6 +528,9 @@ struct Config {
|
||||
instance_id: Option<uuid::Uuid>,
|
||||
ipv4: Option<String>,
|
||||
ipv6: Option<String>,
|
||||
ipv6_public_addr_provider: Option<bool>,
|
||||
ipv6_public_addr_auto: Option<bool>,
|
||||
ipv6_public_addr_prefix: Option<String>,
|
||||
dhcp: Option<bool>,
|
||||
network_identity: Option<NetworkIdentity>,
|
||||
listeners: Option<Vec<url::Url>>,
|
||||
@@ -465,6 +563,7 @@ struct Config {
|
||||
stun_servers_v6: Option<Vec<String>>,
|
||||
|
||||
credential_file: Option<PathBuf>,
|
||||
source: Option<ConfigSourceConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -479,10 +578,21 @@ impl Default for TomlConfigLoader {
|
||||
}
|
||||
|
||||
impl TomlConfigLoader {
|
||||
fn normalize_config_source(config: &mut Config) {
|
||||
if matches!(
|
||||
config.source.as_ref().map(|source| source.source),
|
||||
Some(ConfigSource::User)
|
||||
) {
|
||||
config.source = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_str(config_str: &str) -> Result<Self, anyhow::Error> {
|
||||
let mut config = toml::de::from_str::<Config>(config_str)
|
||||
.with_context(|| format!("failed to parse config file: {}", config_str))?;
|
||||
|
||||
Self::normalize_config_source(&mut config);
|
||||
|
||||
config.flags_struct = Some(Self::gen_flags(config.flags.clone().unwrap_or_default()));
|
||||
|
||||
let config = TomlConfigLoader {
|
||||
@@ -602,6 +712,43 @@ impl ConfigLoader for TomlConfigLoader {
|
||||
self.config.lock().unwrap().ipv6 = addr.map(|addr| addr.to_string());
|
||||
}
|
||||
|
||||
fn get_ipv6_public_addr_provider(&self) -> bool {
|
||||
self.config
|
||||
.lock()
|
||||
.unwrap()
|
||||
.ipv6_public_addr_provider
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn set_ipv6_public_addr_provider(&self, enabled: bool) {
|
||||
self.config.lock().unwrap().ipv6_public_addr_provider = Some(enabled);
|
||||
}
|
||||
|
||||
fn get_ipv6_public_addr_auto(&self) -> bool {
|
||||
self.config
|
||||
.lock()
|
||||
.unwrap()
|
||||
.ipv6_public_addr_auto
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn set_ipv6_public_addr_auto(&self, enabled: bool) {
|
||||
self.config.lock().unwrap().ipv6_public_addr_auto = Some(enabled);
|
||||
}
|
||||
|
||||
fn get_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr> {
|
||||
let locked_config = self.config.lock().unwrap();
|
||||
locked_config
|
||||
.ipv6_public_addr_prefix
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
fn set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>) {
|
||||
self.config.lock().unwrap().ipv6_public_addr_prefix =
|
||||
prefix.map(|prefix| prefix.to_string());
|
||||
}
|
||||
|
||||
fn get_dhcp(&self) -> bool {
|
||||
self.config.lock().unwrap().dhcp.unwrap_or_default()
|
||||
}
|
||||
@@ -866,6 +1013,23 @@ impl ConfigLoader for TomlConfigLoader {
|
||||
self.config.lock().unwrap().credential_file = path;
|
||||
}
|
||||
|
||||
fn get_network_config_source(&self) -> ConfigSource {
|
||||
self.config
|
||||
.lock()
|
||||
.unwrap()
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|source| source.source)
|
||||
.unwrap_or(ConfigSource::User)
|
||||
}
|
||||
|
||||
fn set_network_config_source(&self, source: Option<ConfigSource>) {
|
||||
self.config.lock().unwrap().source = source.and_then(|source| match source {
|
||||
ConfigSource::User => None,
|
||||
other => Some(ConfigSourceConfig { source: other }),
|
||||
});
|
||||
}
|
||||
|
||||
fn dump(&self) -> String {
|
||||
let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap();
|
||||
let default_flags_hashmap =
|
||||
@@ -887,6 +1051,7 @@ impl ConfigLoader for TomlConfigLoader {
|
||||
}
|
||||
|
||||
let mut config = self.config.lock().unwrap().clone();
|
||||
Self::normalize_config_source(&mut config);
|
||||
config.flags = Some(flag_map);
|
||||
if config.stun_servers == Some(StunInfoCollector::get_default_servers()) {
|
||||
config.stun_servers = None;
|
||||
@@ -1125,6 +1290,97 @@ stun_servers = [
|
||||
assert_eq!(stun_servers[2], "txt:stun.easytier.cn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_config_source_toml_roundtrip() {
|
||||
let config = TomlConfigLoader::default();
|
||||
assert_eq!(config.get_network_config_source(), ConfigSource::User);
|
||||
|
||||
config.set_network_config_source(Some(ConfigSource::Webhook));
|
||||
let dumped = config.dump();
|
||||
|
||||
assert!(dumped.contains("[source]"));
|
||||
assert!(dumped.contains("source = \"webhook\""));
|
||||
|
||||
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
|
||||
assert_eq!(loaded.get_network_config_source(), ConfigSource::Webhook);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mapped_listener_urls_allows_ws_without_port() {
|
||||
let parsed = parse_mapped_listener_urls(&[
|
||||
"ws://example.com".to_string(),
|
||||
"wss://example.com/path".to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert_eq!(parsed[0].scheme(), "ws");
|
||||
assert_eq!(parsed[0].port(), None);
|
||||
assert_eq!(parsed[1].scheme(), "wss");
|
||||
assert_eq!(parsed[1].port(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mapped_listener_urls_allows_tcp_without_port() {
|
||||
let parsed = parse_mapped_listener_urls(&["tcp://127.0.0.1".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].scheme(), "tcp");
|
||||
assert_eq!(parsed[0].port(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mapped_listener_urls_requires_port_for_non_ip_scheme() {
|
||||
let err = parse_mapped_listener_urls(&["ring://peer-id".to_string()]).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("mapped listener port is missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_config_source_user_is_implicit() {
|
||||
let config = TomlConfigLoader::default();
|
||||
config.set_network_config_source(Some(ConfigSource::User));
|
||||
let dumped = config.dump();
|
||||
|
||||
assert!(!dumped.contains("[source]"));
|
||||
|
||||
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
|
||||
assert_eq!(loaded.get_network_config_source(), ConfigSource::User);
|
||||
|
||||
let explicit_user = TomlConfigLoader::new_from_str(
|
||||
r#"
|
||||
[source]
|
||||
source = "user"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
explicit_user.get_network_config_source(),
|
||||
ConfigSource::User
|
||||
);
|
||||
assert!(!explicit_user.dump().contains("[source]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv6_public_addr_config_roundtrip() {
|
||||
let config = TomlConfigLoader::default();
|
||||
let prefix: cidr::Ipv6Cidr = "2001:db8:100::/64".parse().unwrap();
|
||||
|
||||
config.set_ipv6_public_addr_provider(true);
|
||||
config.set_ipv6_public_addr_auto(true);
|
||||
config.set_ipv6_public_addr_prefix(Some(prefix));
|
||||
|
||||
assert!(config.get_ipv6_public_addr_provider());
|
||||
assert!(config.get_ipv6_public_addr_auto());
|
||||
assert_eq!(config.get_ipv6_public_addr_prefix(), Some(prefix));
|
||||
|
||||
let dumped = config.dump();
|
||||
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
|
||||
assert!(loaded.get_ipv6_public_addr_provider());
|
||||
assert!(loaded.get_ipv6_public_addr_auto());
|
||||
assert_eq!(loaded.get_ipv6_public_addr_prefix(), Some(prefix));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_example_test() {
|
||||
let config_str = r#"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#[doc(hidden)]
|
||||
pub struct Defer<F: FnOnce()> {
|
||||
// internal struct used by defer! macro
|
||||
func: Option<F>,
|
||||
}
|
||||
|
||||
impl<F: FnOnce()> Defer<F> {
|
||||
pub fn new(func: F) -> Self {
|
||||
Self { func: Some(func) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: FnOnce()> Drop for Defer<F> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(f) = self.func.take() {
|
||||
f()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! defer {
|
||||
( $($tt:tt)* ) => {
|
||||
let _deferred = $crate::common::defer::Defer::new(|| { $($tt)* });
|
||||
};
|
||||
}
|
||||
@@ -121,9 +121,8 @@ pub async fn socket_addrs(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::defer;
|
||||
|
||||
use super::*;
|
||||
use guarden::defer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_socket_addrs() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::{HashMap, hash_map::DefaultHasher},
|
||||
collections::{BTreeSet, HashMap, hash_map::DefaultHasher},
|
||||
hash::Hasher,
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::{Arc, Mutex},
|
||||
@@ -53,6 +53,11 @@ pub enum GlobalCtxEvent {
|
||||
ListenerAcceptFailed(url::Url, String), // (url, error message)
|
||||
ConnectionAccepted(String, String), // (local url, remote url)
|
||||
ConnectionError(String, String, String), // (local url, remote url, error message)
|
||||
ListenerPortMappingEstablished {
|
||||
local_listener: url::Url,
|
||||
mapped_listener: url::Url,
|
||||
backend: String,
|
||||
},
|
||||
|
||||
Connecting(url::Url),
|
||||
ConnectError(String, String, String), // (dst, ip version, error message)
|
||||
@@ -63,6 +68,8 @@ pub enum GlobalCtxEvent {
|
||||
|
||||
DhcpIpv4Changed(Option<cidr::Ipv4Inet>, Option<cidr::Ipv4Inet>), // (old, new)
|
||||
DhcpIpv4Conflicted(Option<cidr::Ipv4Inet>),
|
||||
PublicIpv6Changed(Option<cidr::Ipv6Inet>, Option<cidr::Ipv6Inet>), // (old, new)
|
||||
PublicIpv6RoutesUpdated(Vec<cidr::Ipv6Inet>, Vec<cidr::Ipv6Inet>), // (added, removed)
|
||||
|
||||
PortForwardAdded(PortForwardConfigPb),
|
||||
|
||||
@@ -195,6 +202,8 @@ pub struct GlobalCtx {
|
||||
|
||||
cached_ipv4: AtomicCell<Option<cidr::Ipv4Inet>>,
|
||||
cached_ipv6: AtomicCell<Option<cidr::Ipv6Inet>>,
|
||||
public_ipv6_lease: AtomicCell<Option<cidr::Ipv6Inet>>,
|
||||
public_ipv6_routes: Mutex<BTreeSet<std::net::Ipv6Addr>>,
|
||||
cached_proxy_cidrs: AtomicCell<Option<Vec<ProxyNetworkConfig>>>,
|
||||
|
||||
ip_collector: Mutex<Option<Arc<IPCollector>>>,
|
||||
@@ -204,6 +213,7 @@ pub struct GlobalCtx {
|
||||
stun_info_collection: Mutex<Arc<dyn StunInfoCollectorTrait>>,
|
||||
|
||||
running_listeners: Mutex<Vec<url::Url>>,
|
||||
advertised_ipv6_public_addr_prefix: Mutex<Option<cidr::Ipv6Cidr>>,
|
||||
|
||||
flags: ArcSwap<Flags>,
|
||||
|
||||
@@ -290,6 +300,8 @@ impl GlobalCtx {
|
||||
event_bus,
|
||||
cached_ipv4: AtomicCell::new(None),
|
||||
cached_ipv6: AtomicCell::new(None),
|
||||
public_ipv6_lease: AtomicCell::new(None),
|
||||
public_ipv6_routes: Mutex::new(BTreeSet::new()),
|
||||
cached_proxy_cidrs: AtomicCell::new(None),
|
||||
|
||||
ip_collector: Mutex::new(Some(Arc::new(IPCollector::new(
|
||||
@@ -302,6 +314,7 @@ impl GlobalCtx {
|
||||
stun_info_collection: Mutex::new(stun_info_collector),
|
||||
|
||||
running_listeners: Mutex::new(Vec::new()),
|
||||
advertised_ipv6_public_addr_prefix: Mutex::new(None),
|
||||
|
||||
flags: ArcSwap::new(Arc::new(flags)),
|
||||
|
||||
@@ -376,6 +389,45 @@ impl GlobalCtx {
|
||||
self.cached_ipv6.store(None);
|
||||
}
|
||||
|
||||
pub fn get_public_ipv6_lease(&self) -> Option<cidr::Ipv6Inet> {
|
||||
self.public_ipv6_lease.load()
|
||||
}
|
||||
|
||||
pub fn set_public_ipv6_lease(&self, addr: Option<cidr::Ipv6Inet>) {
|
||||
self.public_ipv6_lease.store(addr);
|
||||
}
|
||||
|
||||
pub fn set_public_ipv6_routes(&self, routes: BTreeSet<cidr::Ipv6Inet>) {
|
||||
*self.public_ipv6_routes.lock().unwrap() =
|
||||
routes.into_iter().map(|route| route.address()).collect();
|
||||
}
|
||||
|
||||
pub fn is_ip_local_ipv6(&self, ip: &std::net::Ipv6Addr) -> bool {
|
||||
self.get_ipv6().map(|x| x.address() == *ip).unwrap_or(false)
|
||||
|| self
|
||||
.get_public_ipv6_lease()
|
||||
.map(|x| x.address() == *ip)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_ip_easytier_managed_ipv6(&self, ip: &std::net::Ipv6Addr) -> bool {
|
||||
self.is_ip_local_ipv6(ip) || self.public_ipv6_routes.lock().unwrap().contains(ip)
|
||||
}
|
||||
|
||||
pub fn get_advertised_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr> {
|
||||
*self.advertised_ipv6_public_addr_prefix.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_advertised_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>) -> bool {
|
||||
let mut guard = self.advertised_ipv6_public_addr_prefix.lock().unwrap();
|
||||
if *guard == prefix {
|
||||
return false;
|
||||
}
|
||||
|
||||
*guard = prefix;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn get_id(&self) -> uuid::Uuid {
|
||||
self.config.get_id()
|
||||
}
|
||||
@@ -390,7 +442,7 @@ impl GlobalCtx {
|
||||
pub fn is_ip_local_virtual_ip(&self, ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => self.get_ipv4().map(|x| x.address() == *v4).unwrap_or(false),
|
||||
IpAddr::V6(v6) => self.get_ipv6().map(|x| x.address() == *v6).unwrap_or(false),
|
||||
IpAddr::V6(v6) => self.is_ip_local_ipv6(v6),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,23 +692,23 @@ impl GlobalCtx {
|
||||
pub fn should_deny_proxy(&self, dst_addr: &SocketAddr, is_udp: bool) -> bool {
|
||||
let _g = self.net_ns.guard();
|
||||
let ip = dst_addr.ip();
|
||||
// first check if ip is virtual ip
|
||||
// first check if ip is an EasyTier-managed local address
|
||||
// then try bind this ip, if succ means it is local ip
|
||||
let dst_is_local_virtual_ip = self.is_ip_local_virtual_ip(&ip);
|
||||
let dst_is_local_et_ip = self.is_ip_local_virtual_ip(&ip);
|
||||
// this is an expensive operation, should be called sparingly
|
||||
// 1. tcp/kcp/quic call this only after proxy conn is established
|
||||
// 2. udp cache the result in nat entry
|
||||
let dst_is_local_phy_ip = std::net::UdpSocket::bind(format!("{}:0", ip)).is_ok();
|
||||
|
||||
tracing::trace!(
|
||||
"check should_deny_proxy: dst_addr={}, dst_is_local_virtual_ip={}, dst_is_local_phy_ip={}, is_udp={}",
|
||||
"check should_deny_proxy: dst_addr={}, dst_is_local_et_ip={}, dst_is_local_phy_ip={}, is_udp={}",
|
||||
dst_addr,
|
||||
dst_is_local_virtual_ip,
|
||||
dst_is_local_et_ip,
|
||||
dst_is_local_phy_ip,
|
||||
is_udp
|
||||
);
|
||||
|
||||
if dst_is_local_virtual_ip || dst_is_local_phy_ip {
|
||||
if dst_is_local_et_ip || dst_is_local_phy_ip {
|
||||
// if is local ip, make sure the port is not one of the listening ports
|
||||
self.is_port_in_running_listeners(dst_addr.port(), is_udp)
|
||||
|| (!is_udp && protected_port::is_protected_tcp_port(dst_addr.port()))
|
||||
@@ -765,6 +817,7 @@ pub mod tests {
|
||||
assert!(feature_flags.support_conn_list_sync);
|
||||
assert!(feature_flags.avoid_relay_data);
|
||||
assert!(feature_flags.is_public_server);
|
||||
assert!(!feature_flags.ipv6_public_addr_provider);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -784,6 +837,40 @@ pub mod tests {
|
||||
protected_port::clear_protected_tcp_ports_for_test();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn virtual_ipv6_and_public_ipv6_lease_are_stored_separately() {
|
||||
let config = TomlConfigLoader::default();
|
||||
let global_ctx = GlobalCtx::new(config);
|
||||
let virtual_ipv6 = "fd00::1/64".parse().unwrap();
|
||||
let public_ipv6 = "2001:db8::2/64".parse().unwrap();
|
||||
|
||||
global_ctx.set_ipv6(Some(virtual_ipv6));
|
||||
global_ctx.set_public_ipv6_lease(Some(public_ipv6));
|
||||
|
||||
assert_eq!(global_ctx.get_ipv6(), Some(virtual_ipv6));
|
||||
assert_eq!(global_ctx.get_public_ipv6_lease(), Some(public_ipv6));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_ipv6_lease_is_treated_as_local_ip() {
|
||||
protected_port::clear_protected_tcp_ports_for_test();
|
||||
|
||||
let config = TomlConfigLoader::default();
|
||||
let global_ctx = GlobalCtx::new(config);
|
||||
let public_ipv6 = "2001:db8::2/64".parse().unwrap();
|
||||
let listener: url::Url = "tcp://[2001:db8::2]:11010".parse().unwrap();
|
||||
global_ctx.set_public_ipv6_lease(Some(public_ipv6));
|
||||
global_ctx.add_running_listener(listener);
|
||||
|
||||
let ip = std::net::IpAddr::V6(public_ipv6.address());
|
||||
let socket = SocketAddr::from((public_ipv6.address(), 11010));
|
||||
|
||||
assert!(global_ctx.is_ip_local_virtual_ip(&ip));
|
||||
assert!(global_ctx.should_deny_proxy(&socket, false));
|
||||
|
||||
protected_port::clear_protected_tcp_ports_for_test();
|
||||
}
|
||||
|
||||
pub fn get_mock_global_ctx_with_network(
|
||||
network_identy: Option<NetworkIdentity>,
|
||||
) -> ArcGlobalCtx {
|
||||
|
||||
@@ -166,3 +166,14 @@ pub type IfConfiger = DummyIfConfiger;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use windows::RegistryManager;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn list_ipv6_route_messages()
|
||||
-> Result<Vec<netlink_packet_route::route::RouteMessage>, Error> {
|
||||
netlink::NetlinkIfConfiger::list_ipv6_route_messages()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn get_interface_index(name: &str) -> Result<u32, Error> {
|
||||
netlink::NetlinkIfConfiger::get_interface_index(name)
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ impl From<RouteMessage> for Route {
|
||||
pub struct NetlinkIfConfiger {}
|
||||
|
||||
impl NetlinkIfConfiger {
|
||||
fn get_interface_index(name: &str) -> Result<u32, Error> {
|
||||
pub(crate) fn get_interface_index(name: &str) -> Result<u32, Error> {
|
||||
let name = CString::new(name).with_context(|| "failed to convert interface name")?;
|
||||
match unsafe { libc::if_nametoindex(name.as_ptr()) } {
|
||||
0 => Err(std::io::Error::last_os_error().into()),
|
||||
@@ -311,7 +311,7 @@ impl NetlinkIfConfiger {
|
||||
Self::set_flags_op(name, SIOCGIFFLAGS, InterfaceFlags::empty())
|
||||
}
|
||||
|
||||
fn list_routes() -> Result<Vec<RouteMessage>, Error> {
|
||||
fn list_route_messages(address_family: AddressFamily) -> Result<Vec<RouteMessage>, Error> {
|
||||
let mut message = RouteMessage::default();
|
||||
|
||||
message.header.table = RouteHeader::RT_TABLE_UNSPEC;
|
||||
@@ -320,7 +320,7 @@ impl NetlinkIfConfiger {
|
||||
message.header.scope = RouteScope::Universe;
|
||||
message.header.kind = RouteType::Unicast;
|
||||
|
||||
message.header.address_family = AddressFamily::Inet;
|
||||
message.header.address_family = address_family;
|
||||
message.header.destination_prefix_length = 0;
|
||||
message.header.source_prefix_length = 0;
|
||||
|
||||
@@ -367,6 +367,14 @@ impl NetlinkIfConfiger {
|
||||
|
||||
Ok(ret_vec)
|
||||
}
|
||||
|
||||
fn list_routes() -> Result<Vec<RouteMessage>, Error> {
|
||||
Self::list_route_messages(AddressFamily::Inet)
|
||||
}
|
||||
|
||||
pub(crate) fn list_ipv6_route_messages() -> Result<Vec<RouteMessage>, Error> {
|
||||
Self::list_route_messages(AddressFamily::Inet6)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -551,12 +559,9 @@ impl IfConfiguerTrait for NetlinkIfConfiger {
|
||||
message.header.scope = RouteScope::Universe;
|
||||
message.header.kind = RouteType::Unicast;
|
||||
|
||||
// Add metric (cost) if specified
|
||||
if let Some(cost) = cost {
|
||||
message
|
||||
.attributes
|
||||
.push(RouteAttribute::Priority(cost as u32));
|
||||
}
|
||||
message
|
||||
.attributes
|
||||
.push(RouteAttribute::Priority(cost.unwrap_or(65535) as u32));
|
||||
|
||||
message
|
||||
.attributes
|
||||
@@ -564,9 +569,11 @@ impl IfConfiguerTrait for NetlinkIfConfiger {
|
||||
name,
|
||||
)?));
|
||||
|
||||
message
|
||||
.attributes
|
||||
.push(RouteAttribute::Destination(RouteAddress::Inet6(address)));
|
||||
if cidr_prefix != 0 {
|
||||
message
|
||||
.attributes
|
||||
.push(RouteAttribute::Destination(RouteAddress::Inet6(address)));
|
||||
}
|
||||
|
||||
send_netlink_req_and_wait_one_resp(RouteNetlinkMessage::NewRoute(message), false)
|
||||
}
|
||||
@@ -577,7 +584,7 @@ impl IfConfiguerTrait for NetlinkIfConfiger {
|
||||
address: std::net::Ipv6Addr,
|
||||
cidr_prefix: u8,
|
||||
) -> Result<(), Error> {
|
||||
let routes = Self::list_routes()?;
|
||||
let routes = Self::list_route_messages(AddressFamily::Inet6)?;
|
||||
let ifidx = NetlinkIfConfiger::get_interface_index(name)?;
|
||||
|
||||
for msg in routes {
|
||||
@@ -598,29 +605,82 @@ impl IfConfiguerTrait for NetlinkIfConfiger {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
|
||||
const DUMMY_IFACE_NAME: &str = "dummy";
|
||||
|
||||
fn run_cmd(cmd: &str) -> String {
|
||||
let output = std::process::Command::new("sh")
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.expect("failed to execute process");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"command failed: {cmd}\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
|
||||
fn run_ip(args: &[&str]) {
|
||||
let output = Command::new("ip")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("failed to execute ip process");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ip command failed: {:?}\nstdout: {}\nstderr: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
fn test_iface_name(tag: &str) -> String {
|
||||
format!("et{}{:x}", tag, std::process::id() & 0xffff)
|
||||
}
|
||||
|
||||
struct ScopedDummyLink {
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl ScopedDummyLink {
|
||||
fn new(name: &str) -> Self {
|
||||
let _ = Command::new("ip").args(["link", "del", name]).output();
|
||||
run_ip(&["link", "add", name, "type", "dummy"]);
|
||||
run_ip(&["link", "set", name, "up"]);
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScopedDummyLink {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("ip")
|
||||
.args(["link", "del", &self.name])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
struct PrepareEnv {}
|
||||
impl PrepareEnv {
|
||||
fn new() -> Self {
|
||||
let _ = run_cmd(&format!("sudo ip link add {} type dummy", DUMMY_IFACE_NAME));
|
||||
let _ = Command::new("ip")
|
||||
.args(["link", "del", DUMMY_IFACE_NAME])
|
||||
.output();
|
||||
let _ = run_cmd(&format!("ip link add {} type dummy", DUMMY_IFACE_NAME));
|
||||
PrepareEnv {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PrepareEnv {
|
||||
fn drop(&mut self) {
|
||||
let _ = run_cmd(&format!("sudo ip link del {}", DUMMY_IFACE_NAME));
|
||||
let _ = Command::new("ip")
|
||||
.args(["link", "del", DUMMY_IFACE_NAME])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,4 +761,128 @@ mod tests {
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!routes.contains(&IpAddr::V4("10.5.5.0".parse().unwrap())));
|
||||
}
|
||||
|
||||
#[serial_test::serial]
|
||||
#[tokio::test]
|
||||
async fn ipv6_addr_readback_test() {
|
||||
let iface = test_iface_name("a");
|
||||
let _link = ScopedDummyLink::new(&iface);
|
||||
run_ip(&["-6", "addr", "add", "2001:db8:1234::2/64", "dev", &iface]);
|
||||
|
||||
let addrs = NetlinkIfConfiger::list_addresses(&iface).unwrap();
|
||||
assert!(addrs.iter().any(|addr| {
|
||||
addr.address() == IpAddr::V6("2001:db8:1234::2".parse().unwrap())
|
||||
&& addr.network_length() == 64
|
||||
}));
|
||||
}
|
||||
|
||||
#[serial_test::serial]
|
||||
#[tokio::test]
|
||||
async fn ipv6_route_readback_test() {
|
||||
let wan_if = test_iface_name("rw");
|
||||
let lan_if = test_iface_name("rl");
|
||||
let _wan = ScopedDummyLink::new(&wan_if);
|
||||
let _lan = ScopedDummyLink::new(&lan_if);
|
||||
run_ip(&[
|
||||
"-6",
|
||||
"addr",
|
||||
"add",
|
||||
"2001:db8:100:ffff::2/64",
|
||||
"dev",
|
||||
&wan_if,
|
||||
]);
|
||||
run_ip(&[
|
||||
"-6",
|
||||
"route",
|
||||
"add",
|
||||
"default",
|
||||
"from",
|
||||
"2001:db8:100::/56",
|
||||
"dev",
|
||||
&wan_if,
|
||||
]);
|
||||
run_ip(&["-6", "route", "add", "2001:db8:100::/56", "dev", &lan_if]);
|
||||
|
||||
let wan_ifindex = NetlinkIfConfiger::get_interface_index(&wan_if).unwrap();
|
||||
let lan_ifindex = NetlinkIfConfiger::get_interface_index(&lan_if).unwrap();
|
||||
let routes = NetlinkIfConfiger::list_ipv6_route_messages().unwrap();
|
||||
|
||||
assert!(routes.iter().any(|route| {
|
||||
route.header.kind == RouteType::Unicast
|
||||
&& route.header.source_prefix_length == 56
|
||||
&& route.attributes.iter().any(|attr| {
|
||||
matches!(
|
||||
attr,
|
||||
RouteAttribute::Source(RouteAddress::Inet6(addr))
|
||||
if *addr == "2001:db8:100::".parse::<std::net::Ipv6Addr>().unwrap()
|
||||
)
|
||||
})
|
||||
&& route
|
||||
.attributes
|
||||
.iter()
|
||||
.any(|attr| matches!(attr, RouteAttribute::Oif(index) if *index == wan_ifindex))
|
||||
&& !route
|
||||
.attributes
|
||||
.iter()
|
||||
.any(|attr| matches!(attr, RouteAttribute::Destination(_)))
|
||||
}));
|
||||
|
||||
assert!(routes.iter().any(|route| {
|
||||
route.header.kind == RouteType::Unicast
|
||||
&& route.header.destination_prefix_length == 56
|
||||
&& route.attributes.iter().any(|attr| {
|
||||
matches!(
|
||||
attr,
|
||||
RouteAttribute::Destination(RouteAddress::Inet6(addr))
|
||||
if *addr == "2001:db8:100::".parse::<std::net::Ipv6Addr>().unwrap()
|
||||
)
|
||||
})
|
||||
&& route
|
||||
.attributes
|
||||
.iter()
|
||||
.any(|attr| matches!(attr, RouteAttribute::Oif(index) if *index == lan_ifindex))
|
||||
}));
|
||||
}
|
||||
|
||||
#[serial_test::serial]
|
||||
#[tokio::test]
|
||||
async fn ipv6_route_remove_test() {
|
||||
let iface = test_iface_name("rr");
|
||||
let _link = ScopedDummyLink::new(&iface);
|
||||
let ifcfg = NetlinkIfConfiger {};
|
||||
let route_addr = "2001:db8:200::".parse::<std::net::Ipv6Addr>().unwrap();
|
||||
|
||||
ifcfg
|
||||
.add_ipv6_route(&iface, route_addr, 56, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ifindex = NetlinkIfConfiger::get_interface_index(&iface).unwrap();
|
||||
let has_route = |routes: &[RouteMessage]| {
|
||||
routes.iter().any(|route| {
|
||||
route.header.destination_prefix_length == 56
|
||||
&& route.attributes.iter().any(|attr| {
|
||||
matches!(
|
||||
attr,
|
||||
RouteAttribute::Destination(RouteAddress::Inet6(addr)) if *addr == route_addr
|
||||
)
|
||||
})
|
||||
&& route
|
||||
.attributes
|
||||
.iter()
|
||||
.any(|attr| matches!(attr, RouteAttribute::Oif(index) if *index == ifindex))
|
||||
})
|
||||
};
|
||||
|
||||
let routes = NetlinkIfConfiger::list_ipv6_route_messages().unwrap();
|
||||
assert!(has_route(&routes));
|
||||
|
||||
ifcfg
|
||||
.remove_ipv6_route(&iface, route_addr, 56)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let routes = NetlinkIfConfiger::list_ipv6_route_messages().unwrap();
|
||||
assert!(!has_route(&routes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@ use cidr::{Ipv4Inet, Ipv6Inet};
|
||||
use std::{
|
||||
io,
|
||||
net::{Ipv4Addr, Ipv6Addr},
|
||||
ptr::null_mut,
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
use windows::Win32::NetworkManagement::IpHelper::INTERNAL_IF_OPER_STATUS;
|
||||
use windows::Win32::{
|
||||
Foundation::NO_ERROR,
|
||||
NetworkManagement::IpHelper::{GetIfEntry, MIB_IFROW, SetIfEntry},
|
||||
System::Diagnostics::Debug::{
|
||||
FORMAT_MESSAGE_FROM_SYSTEM, FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageW,
|
||||
},
|
||||
};
|
||||
use windows::core::PWSTR;
|
||||
use winreg::{
|
||||
RegKey,
|
||||
enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WRITE},
|
||||
@@ -32,12 +33,12 @@ fn format_win_error(error: u32) -> String {
|
||||
unsafe {
|
||||
FormatMessageW(
|
||||
flags,
|
||||
null_mut(),
|
||||
None,
|
||||
error,
|
||||
0,
|
||||
buffer.as_mut_ptr(),
|
||||
PWSTR(buffer.as_mut_ptr()),
|
||||
size,
|
||||
null_mut(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
let str_end = buffer.iter().position(|&b| b == 0).unwrap_or(buffer.len());
|
||||
@@ -100,7 +101,7 @@ impl WindowsIfConfiger {
|
||||
dwPhysAddrLen: 0,
|
||||
bPhysAddr: [0; 8],
|
||||
dwAdminStatus: if up { 1 } else { 2 }, // 1 = up, 2 = down
|
||||
dwOperStatus: 0,
|
||||
dwOperStatus: INTERNAL_IF_OPER_STATUS(0),
|
||||
dwLastChange: 0,
|
||||
dwInOctets: 0,
|
||||
dwInUcastPkts: 0,
|
||||
@@ -118,8 +119,8 @@ impl WindowsIfConfiger {
|
||||
bDescr: [0; 256],
|
||||
};
|
||||
|
||||
if GetIfEntry(&mut if_row) == NO_ERROR {
|
||||
if SetIfEntry(&if_row) == NO_ERROR {
|
||||
if GetIfEntry(&mut if_row) == NO_ERROR.0 {
|
||||
if SetIfEntry(&if_row) == NO_ERROR.0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Failed to set interface status").into())
|
||||
|
||||
@@ -14,7 +14,6 @@ pub mod acl_processor;
|
||||
pub mod compressor;
|
||||
pub mod config;
|
||||
pub mod constants;
|
||||
pub mod defer;
|
||||
pub mod dns;
|
||||
pub mod env_parser;
|
||||
pub mod error;
|
||||
@@ -25,12 +24,12 @@ pub mod log;
|
||||
pub mod netns;
|
||||
pub mod network;
|
||||
pub mod os_info;
|
||||
pub mod scoped_task;
|
||||
pub mod stats_manager;
|
||||
pub mod stun;
|
||||
pub mod stun_codec_ext;
|
||||
pub mod token_bucket;
|
||||
pub mod tracing_rolling_appender;
|
||||
pub mod upnp;
|
||||
|
||||
pub fn get_logger_timer<F: time::formatting::Formattable>(
|
||||
format: F,
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
//! This crate provides a wrapper type of Tokio's JoinHandle: `ScopedTask`, which aborts the task when it's dropped.
|
||||
//! `ScopedTask` can still be awaited to join the child-task, and abort-on-drop will still trigger while it is being awaited.
|
||||
//!
|
||||
//! For example, if task A spawned task B but is doing something else, and task B is waiting for task C to join,
|
||||
//! aborting A will also abort both B and C.
|
||||
|
||||
use derive_more::{Deref, DerefMut, From};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
#[derive(Debug, From, Deref, DerefMut)]
|
||||
pub struct ScopedTask<T>(JoinHandle<T>);
|
||||
|
||||
impl<T> Drop for ScopedTask<T> {
|
||||
fn drop(&mut self) {
|
||||
self.abort()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Future for ScopedTask<T> {
|
||||
type Output = <JoinHandle<T> as Future>::Output;
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
Pin::new(&mut self.0).poll(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ScopedTask;
|
||||
use futures_util::future::pending;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::task::yield_now;
|
||||
|
||||
struct Sentry(Arc<RwLock<bool>>);
|
||||
impl Drop for Sentry {
|
||||
fn drop(&mut self) {
|
||||
*self.0.write().unwrap() = true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_while_not_waiting_for_join() {
|
||||
let dropped = Arc::new(RwLock::new(false));
|
||||
let sentry = Sentry(dropped.clone());
|
||||
let task = ScopedTask::from(tokio::spawn(async move {
|
||||
let _sentry = sentry;
|
||||
pending::<()>().await
|
||||
}));
|
||||
yield_now().await;
|
||||
assert!(!*dropped.read().unwrap());
|
||||
drop(task);
|
||||
yield_now().await;
|
||||
assert!(*dropped.read().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_while_waiting_for_join() {
|
||||
let dropped = Arc::new(RwLock::new(false));
|
||||
let sentry = Sentry(dropped.clone());
|
||||
let handle = tokio::spawn(async move {
|
||||
ScopedTask::from(tokio::spawn(async move {
|
||||
let _sentry = sentry;
|
||||
pending::<()>().await
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
yield_now().await;
|
||||
assert!(!*dropped.read().unwrap());
|
||||
handle.abort();
|
||||
yield_now().await;
|
||||
assert!(*dropped.read().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_drop_only_join() {
|
||||
assert_eq!(
|
||||
ScopedTask::from(tokio::spawn(async {
|
||||
yield_now().await;
|
||||
5
|
||||
}))
|
||||
.await
|
||||
.unwrap(),
|
||||
5
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manually_abort_before_drop() {
|
||||
let dropped = Arc::new(RwLock::new(false));
|
||||
let sentry = Sentry(dropped.clone());
|
||||
let task = ScopedTask::from(tokio::spawn(async move {
|
||||
let _sentry = sentry;
|
||||
pending::<()>().await
|
||||
}));
|
||||
yield_now().await;
|
||||
assert!(!*dropped.read().unwrap());
|
||||
task.abort();
|
||||
yield_now().await;
|
||||
assert!(*dropped.read().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manually_abort_then_join() {
|
||||
let dropped = Arc::new(RwLock::new(false));
|
||||
let sentry = Sentry(dropped.clone());
|
||||
let task = ScopedTask::from(tokio::spawn(async move {
|
||||
let _sentry = sentry;
|
||||
pending::<()>().await
|
||||
}));
|
||||
yield_now().await;
|
||||
assert!(!*dropped.read().unwrap());
|
||||
task.abort();
|
||||
yield_now().await;
|
||||
assert!(task.await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
/// Predefined metric names for type safety
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -578,7 +577,7 @@ impl MetricSnapshot {
|
||||
/// StatsManager manages global statistics with high performance counters
|
||||
pub struct StatsManager {
|
||||
counters: Arc<DashMap<MetricKey, Arc<MetricData>>>,
|
||||
cleanup_task: ScopedTask<()>,
|
||||
cleanup_task: AbortOnDropHandle<()>,
|
||||
}
|
||||
|
||||
impl StatsManager {
|
||||
@@ -611,7 +610,7 @@ impl StatsManager {
|
||||
|
||||
Self {
|
||||
counters,
|
||||
cleanup_task: cleanup_task.into(),
|
||||
cleanup_task: AbortOnDropHandle::new(cleanup_task),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,6 @@ impl StunClient {
|
||||
let stun_host = self.stun_server;
|
||||
// repeat req in case of packet loss
|
||||
let mut tids = vec![];
|
||||
|
||||
for _ in 0..self.req_repeat {
|
||||
let tid = rand::random::<u32>();
|
||||
// let tid = 1;
|
||||
@@ -912,6 +911,10 @@ impl TcpNatTypeDetector {
|
||||
pub trait StunInfoCollectorTrait: Send + Sync {
|
||||
fn get_stun_info(&self) -> StunInfo;
|
||||
async fn get_udp_port_mapping(&self, local_port: u16) -> Result<SocketAddr, Error>;
|
||||
async fn get_udp_port_mapping_with_socket(
|
||||
&self,
|
||||
udp: Arc<UdpSocket>,
|
||||
) -> Result<SocketAddr, Error>;
|
||||
async fn get_tcp_port_mapping(&self, local_port: u16) -> Result<SocketAddr, Error>;
|
||||
}
|
||||
|
||||
@@ -975,6 +978,14 @@ impl StunInfoCollectorTrait for StunInfoCollector {
|
||||
}
|
||||
|
||||
async fn get_udp_port_mapping(&self, local_port: u16) -> Result<SocketAddr, Error> {
|
||||
let udp = Arc::new(UdpSocket::bind(format!("0.0.0.0:{}", local_port)).await?);
|
||||
self.get_udp_port_mapping_with_socket(udp).await
|
||||
}
|
||||
|
||||
async fn get_udp_port_mapping_with_socket(
|
||||
&self,
|
||||
udp: Arc<UdpSocket>,
|
||||
) -> Result<SocketAddr, Error> {
|
||||
self.start_stun_routine();
|
||||
|
||||
let mut stun_servers = self
|
||||
@@ -1000,7 +1011,6 @@ impl StunInfoCollectorTrait for StunInfoCollector {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
let udp = Arc::new(UdpSocket::bind(format!("0.0.0.0:{}", local_port)).await?);
|
||||
let mut client_builder = StunClientBuilder::new(udp.clone());
|
||||
|
||||
for server in stun_servers.iter() {
|
||||
@@ -1316,6 +1326,13 @@ impl StunInfoCollectorTrait for MockStunInfoCollector {
|
||||
Ok(format!("127.0.0.1:{}", port).parse().unwrap())
|
||||
}
|
||||
|
||||
async fn get_udp_port_mapping_with_socket(
|
||||
&self,
|
||||
udp: Arc<UdpSocket>,
|
||||
) -> Result<std::net::SocketAddr, Error> {
|
||||
self.get_udp_port_mapping(udp.local_addr()?.port()).await
|
||||
}
|
||||
|
||||
async fn get_tcp_port_mapping(&self, mut port: u16) -> Result<std::net::SocketAddr, Error> {
|
||||
if port == 0 {
|
||||
port = 40144;
|
||||
@@ -1326,11 +1343,9 @@ impl StunInfoCollectorTrait for MockStunInfoCollector {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
common::scoped_task::ScopedTask,
|
||||
tunnel::{TunnelListener, udp::UdpTunnelListener},
|
||||
};
|
||||
use crate::tunnel::{TunnelListener, udp::UdpTunnelListener};
|
||||
use tokio::time::{sleep, timeout};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1424,7 +1439,7 @@ mod tests {
|
||||
use stun_codec::rfc5389::attributes::XorMappedAddress;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
async fn spawn_tcp_stun_server() -> (SocketAddr, ScopedTask<()>) {
|
||||
async fn spawn_tcp_stun_server() -> (SocketAddr, AbortOnDropHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_addr = listener.local_addr().unwrap();
|
||||
|
||||
@@ -1448,7 +1463,7 @@ mod tests {
|
||||
stream.write_all(rsp_buf.as_slice()).await.unwrap();
|
||||
});
|
||||
|
||||
(server_addr, task.into())
|
||||
(server_addr, AbortOnDropHandle::new(task))
|
||||
}
|
||||
|
||||
let (server1, _t1) = spawn_tcp_stun_server().await;
|
||||
@@ -1487,7 +1502,7 @@ mod tests {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_addr = listener.local_addr().unwrap();
|
||||
|
||||
let _t = ScopedTask::from(tokio::spawn(async move {
|
||||
let _t = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
for _ in 0..8 {
|
||||
let Ok((mut stream, peer_addr)) = listener.accept().await else {
|
||||
break;
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
use tokio::time;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use crate::proto::common::LimiterConfig;
|
||||
|
||||
/// Token Bucket rate limiter using atomic operations
|
||||
@@ -14,7 +14,7 @@ pub struct TokenBucket {
|
||||
available_tokens: AtomicU64, // Current token count (atomic)
|
||||
last_refill_time: AtomicU64, // Last refill time as micros since epoch
|
||||
config: BucketConfig, // Immutable configuration
|
||||
refill_task: Mutex<Option<ScopedTask<()>>>, // Background refill task
|
||||
refill_task: Mutex<Option<AbortOnDropHandle<()>>>, // Background refill task
|
||||
start_time: Instant, // Bucket creation time
|
||||
|
||||
refill_notifier: Arc<Notify>,
|
||||
@@ -91,7 +91,7 @@ impl TokenBucket {
|
||||
.refill_task
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace(refill_task.into());
|
||||
.replace(AbortOnDropHandle::new(refill_task));
|
||||
arc_self
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ impl TokenBucket {
|
||||
pub struct TokenBucketManager {
|
||||
buckets: Arc<DashMap<String, Arc<TokenBucket>>>,
|
||||
|
||||
retain_task: ScopedTask<()>,
|
||||
retain_task: AbortOnDropHandle<()>,
|
||||
}
|
||||
|
||||
impl Default for TokenBucketManager {
|
||||
@@ -205,7 +205,7 @@ impl TokenBucketManager {
|
||||
|
||||
Self {
|
||||
buckets,
|
||||
retain_task: retain_task.into(),
|
||||
retain_task: AbortOnDropHandle::new(retain_task),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
use std::{
|
||||
fmt,
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use anyhow::{Context, anyhow, bail};
|
||||
use igd_next::{
|
||||
AddAnyPortError, PortMappingProtocol, SearchOptions,
|
||||
aio::{
|
||||
Gateway,
|
||||
tokio::{Tokio, search_gateway},
|
||||
},
|
||||
};
|
||||
use natpmp::{
|
||||
Protocol as NatPmpProtocol, Response as NatPmpResponse, new_tokio_natpmp, new_tokio_natpmp_with,
|
||||
};
|
||||
use tokio::{net::UdpSocket, sync::oneshot};
|
||||
|
||||
use super::{
|
||||
global_ctx::{ArcGlobalCtx, GlobalCtxEvent},
|
||||
stun::StunInfoCollectorTrait as _,
|
||||
};
|
||||
use crate::tunnel::build_url_from_socket_addr;
|
||||
|
||||
const UPNP_SEARCH_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const UPNP_SEARCH_RESPONSE_TIMEOUT: Duration = Duration::from_millis(300);
|
||||
const NAT_PMP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const UPNP_LEASE_DURATION_SECS: u32 = 300;
|
||||
const UPNP_RENEW_INTERVAL: Duration = Duration::from_secs(240);
|
||||
const UPNP_DESCRIPTION: &str = "EasyTier udp hole punch";
|
||||
const PORT_MAPPING_BACKEND_NAT_PMP: &str = "nat-pmp";
|
||||
const PORT_MAPPING_BACKEND_IGD: &str = "igd";
|
||||
|
||||
type TokioGateway = Gateway<Tokio>;
|
||||
|
||||
#[cfg(test)]
|
||||
static UDP_PORT_MAPPING_ATTEMPTS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_udp_port_mapping_attempts_for_test() {
|
||||
UDP_PORT_MAPPING_ATTEMPTS.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn udp_port_mapping_attempts_for_test() -> usize {
|
||||
UDP_PORT_MAPPING_ATTEMPTS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
enum PortMappingBackend {
|
||||
NatPmp { gateway: Ipv4Addr },
|
||||
Igd { gateway: TokioGateway },
|
||||
}
|
||||
|
||||
impl PortMappingBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NatPmp { .. } => PORT_MAPPING_BACKEND_NAT_PMP,
|
||||
Self::Igd { .. } => PORT_MAPPING_BACKEND_IGD,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveUdpPortMapping {
|
||||
backend: PortMappingBackend,
|
||||
local_listener: url::Url,
|
||||
local_addr: SocketAddr,
|
||||
gateway_external_port: u16,
|
||||
}
|
||||
|
||||
impl ActiveUdpPortMapping {
|
||||
async fn discover_nat_pmp_gateway(
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<(Ipv4Addr, SocketAddr)> {
|
||||
let client = new_tokio_natpmp().await.context("create nat-pmp client")?;
|
||||
let gateway = *client.gateway();
|
||||
let gateway_addr = SocketAddr::V4(SocketAddrV4::new(gateway, natpmp::NATPMP_PORT));
|
||||
let local_addr = resolve_internal_addr(gateway_addr, local_listener).await?;
|
||||
Ok((gateway, local_addr))
|
||||
}
|
||||
|
||||
async fn establish_via_nat_pmp(
|
||||
local_listener: &url::Url,
|
||||
gateway: Ipv4Addr,
|
||||
local_addr: SocketAddr,
|
||||
) -> anyhow::Result<Self> {
|
||||
let gateway_external_port =
|
||||
add_udp_mapping_port_nat_pmp(gateway, local_addr, local_listener)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("map udp socket for {local_listener} via nat-pmp gateway {gateway}")
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
backend: PortMappingBackend::NatPmp { gateway },
|
||||
local_listener: local_listener.clone(),
|
||||
local_addr,
|
||||
gateway_external_port,
|
||||
})
|
||||
}
|
||||
|
||||
async fn discover_igd_gateway(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<(TokioGateway, SocketAddr)> {
|
||||
let _g = global_ctx.net_ns.guard();
|
||||
let gateway = search_gateway(SearchOptions {
|
||||
timeout: Some(UPNP_SEARCH_TIMEOUT),
|
||||
single_search_timeout: Some(UPNP_SEARCH_RESPONSE_TIMEOUT),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.with_context(|| format!("search igd gateway for {local_listener}"))?;
|
||||
let local_addr = resolve_internal_addr(gateway.addr, local_listener).await?;
|
||||
|
||||
Ok((gateway, local_addr))
|
||||
}
|
||||
|
||||
async fn establish_via_igd(
|
||||
local_listener: &url::Url,
|
||||
gateway: TokioGateway,
|
||||
local_addr: SocketAddr,
|
||||
) -> anyhow::Result<Self> {
|
||||
let gateway_external_port = add_udp_mapping_port_igd(&gateway, local_addr, local_listener)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"map udp socket for {local_listener} via gateway {}",
|
||||
gateway.addr
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
backend: PortMappingBackend::Igd { gateway },
|
||||
local_listener: local_listener.clone(),
|
||||
local_addr,
|
||||
gateway_external_port,
|
||||
})
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
self.backend.name()
|
||||
}
|
||||
|
||||
async fn renew(&self) -> anyhow::Result<()> {
|
||||
match &self.backend {
|
||||
PortMappingBackend::NatPmp { gateway } => {
|
||||
renew_udp_mapping_nat_pmp(
|
||||
*gateway,
|
||||
self.local_addr,
|
||||
self.gateway_external_port,
|
||||
&self.local_listener,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PortMappingBackend::Igd { gateway } => {
|
||||
renew_udp_mapping_igd(
|
||||
gateway,
|
||||
self.local_addr,
|
||||
self.gateway_external_port,
|
||||
&self.local_listener,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove(&self) -> anyhow::Result<()> {
|
||||
match &self.backend {
|
||||
PortMappingBackend::NatPmp { gateway } => {
|
||||
remove_udp_mapping_nat_pmp(
|
||||
*gateway,
|
||||
self.local_addr,
|
||||
self.gateway_external_port,
|
||||
&self.local_listener,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PortMappingBackend::Igd { gateway } => {
|
||||
remove_udp_mapping_igd(gateway, self.gateway_external_port, &self.local_listener)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UdpPortMappingLease {
|
||||
backend: &'static str,
|
||||
gateway_external_port: u16,
|
||||
stop_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl UdpPortMappingLease {
|
||||
pub fn backend(&self) -> &'static str {
|
||||
self.backend
|
||||
}
|
||||
|
||||
pub fn gateway_external_port(&self) -> u16 {
|
||||
self.gateway_external_port
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for UdpPortMappingLease {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("UdpPortMappingLease")
|
||||
.field("backend", &self.backend)
|
||||
.field("gateway_external_port", &self.gateway_external_port)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for UdpPortMappingLease {
|
||||
fn drop(&mut self) {
|
||||
if let Some(stop_tx) = self.stop_tx.take() {
|
||||
let _ = stop_tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_udp_public_addr(
|
||||
global_ctx: ArcGlobalCtx,
|
||||
local_listener: &url::Url,
|
||||
socket: Arc<UdpSocket>,
|
||||
) -> anyhow::Result<(SocketAddr, Option<UdpPortMappingLease>)> {
|
||||
let port_mapping = match try_start_udp_port_mapping(&global_ctx, local_listener).await {
|
||||
Ok(mapping) => mapping,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
%local_listener,
|
||||
"failed to establish udp port mapping, fallback to stun-only public addr resolution"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let mapped_addr = global_ctx
|
||||
.get_stun_info_collector()
|
||||
.get_udp_port_mapping_with_socket(socket)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
.with_context(|| format!("resolve udp public addr for {local_listener}"))?;
|
||||
|
||||
if let Some(port_mapping) = port_mapping.as_ref() {
|
||||
let mapped_listener = build_url_from_socket_addr(&mapped_addr.to_string(), "udp");
|
||||
global_ctx.issue_event(GlobalCtxEvent::ListenerPortMappingEstablished {
|
||||
local_listener: local_listener.clone(),
|
||||
mapped_listener,
|
||||
backend: port_mapping.backend().to_string(),
|
||||
});
|
||||
tracing::info!(
|
||||
%local_listener,
|
||||
backend = port_mapping.backend(),
|
||||
gateway_external_port = port_mapping.gateway_external_port(),
|
||||
stun_mapped_addr = %mapped_addr,
|
||||
"udp public addr resolved after port mapping"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
%local_listener,
|
||||
stun_mapped_addr = %mapped_addr,
|
||||
"udp public addr resolved without port mapping"
|
||||
);
|
||||
}
|
||||
|
||||
Ok((mapped_addr, port_mapping))
|
||||
}
|
||||
|
||||
async fn try_start_udp_port_mapping(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<Option<UdpPortMappingLease>> {
|
||||
if global_ctx.get_flags().disable_upnp || !should_map_udp_listener(local_listener) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
UDP_PORT_MAPPING_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let mapping = discover_udp_port_mapping(global_ctx.clone(), local_listener.clone()).await?;
|
||||
tracing::info!(
|
||||
%local_listener,
|
||||
backend = mapping.backend_name(),
|
||||
local_addr = %mapping.local_addr,
|
||||
gateway_external_port = mapping.gateway_external_port,
|
||||
"udp port mapping established"
|
||||
);
|
||||
|
||||
let backend = mapping.backend_name();
|
||||
let gateway_external_port = mapping.gateway_external_port;
|
||||
let runtime_global_ctx = global_ctx.clone();
|
||||
let runtime_local_listener = local_listener.clone();
|
||||
let (stop_tx, stop_rx) = oneshot::channel();
|
||||
if should_run_port_mapping_in_dedicated_thread(&runtime_global_ctx) {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _g = runtime_global_ctx.net_ns.guard();
|
||||
match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => {
|
||||
runtime.block_on(run_udp_port_mapping_task(
|
||||
runtime_local_listener,
|
||||
mapping,
|
||||
stop_rx,
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
?err,
|
||||
%runtime_local_listener,
|
||||
"failed to build runtime for udp port mapping renew task"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tokio::spawn(run_udp_port_mapping_task(
|
||||
runtime_local_listener,
|
||||
mapping,
|
||||
stop_rx,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(UdpPortMappingLease {
|
||||
backend,
|
||||
gateway_external_port,
|
||||
stop_tx: Some(stop_tx),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn discover_udp_port_mapping(
|
||||
global_ctx: ArcGlobalCtx,
|
||||
local_listener: url::Url,
|
||||
) -> anyhow::Result<ActiveUdpPortMapping> {
|
||||
match discover_igd_gateway_in_netns(global_ctx.clone(), local_listener.clone()).await {
|
||||
Ok((gateway, local_addr)) => match establish_igd_mapping_in_netns(
|
||||
global_ctx.clone(),
|
||||
local_listener.clone(),
|
||||
gateway,
|
||||
local_addr,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mapping) => Ok(mapping),
|
||||
Err(igd_err) => {
|
||||
tracing::debug!(
|
||||
?igd_err,
|
||||
%local_listener,
|
||||
"igd udp port mapping failed, retry with nat-pmp"
|
||||
);
|
||||
match discover_nat_pmp_gateway_in_netns(global_ctx.clone(), local_listener.clone())
|
||||
.await
|
||||
{
|
||||
Ok((gateway, local_addr)) => establish_nat_pmp_mapping_in_netns(
|
||||
global_ctx,
|
||||
local_listener.clone(),
|
||||
gateway,
|
||||
local_addr,
|
||||
)
|
||||
.await
|
||||
.map_err(|nat_pmp_err| {
|
||||
anyhow!(
|
||||
"udp port mapping failed for {local_listener}: igd error: {igd_err}; nat-pmp error: {nat_pmp_err}"
|
||||
)
|
||||
}),
|
||||
Err(nat_pmp_err) => Err(anyhow!(
|
||||
"udp port mapping failed for {local_listener}: igd error: {igd_err}; nat-pmp discovery error: {nat_pmp_err}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(igd_err) => {
|
||||
tracing::debug!(
|
||||
?igd_err,
|
||||
%local_listener,
|
||||
"igd gateway discovery failed, retry with nat-pmp"
|
||||
);
|
||||
match discover_nat_pmp_gateway_in_netns(global_ctx.clone(), local_listener.clone()).await
|
||||
{
|
||||
Ok((gateway, local_addr)) => establish_nat_pmp_mapping_in_netns(
|
||||
global_ctx,
|
||||
local_listener.clone(),
|
||||
gateway,
|
||||
local_addr,
|
||||
)
|
||||
.await
|
||||
.map_err(|nat_pmp_err| {
|
||||
anyhow!(
|
||||
"udp port mapping failed for {local_listener}: igd discovery error: {igd_err}; nat-pmp error: {nat_pmp_err}"
|
||||
)
|
||||
}),
|
||||
Err(nat_pmp_err) => Err(anyhow!(
|
||||
"udp port mapping failed for {local_listener}: igd discovery error: {igd_err}; nat-pmp discovery error: {nat_pmp_err}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_igd_gateway_in_netns(
|
||||
global_ctx: ArcGlobalCtx,
|
||||
local_listener: url::Url,
|
||||
) -> anyhow::Result<(TokioGateway, SocketAddr)> {
|
||||
if !should_run_port_mapping_in_dedicated_thread(&global_ctx) {
|
||||
return ActiveUdpPortMapping::discover_igd_gateway(&global_ctx, &local_listener).await;
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _g = global_ctx.net_ns.guard();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build runtime for igd gateway discovery")?
|
||||
.block_on(ActiveUdpPortMapping::discover_igd_gateway(
|
||||
&global_ctx,
|
||||
&local_listener,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.context("join igd gateway discovery task")?
|
||||
}
|
||||
|
||||
async fn establish_igd_mapping_in_netns(
|
||||
global_ctx: ArcGlobalCtx,
|
||||
local_listener: url::Url,
|
||||
gateway: TokioGateway,
|
||||
local_addr: SocketAddr,
|
||||
) -> anyhow::Result<ActiveUdpPortMapping> {
|
||||
if !should_run_port_mapping_in_dedicated_thread(&global_ctx) {
|
||||
return ActiveUdpPortMapping::establish_via_igd(&local_listener, gateway, local_addr).await;
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _g = global_ctx.net_ns.guard();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build runtime for igd mapping establishment")?
|
||||
.block_on(ActiveUdpPortMapping::establish_via_igd(
|
||||
&local_listener,
|
||||
gateway,
|
||||
local_addr,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.context("join igd mapping establishment task")?
|
||||
}
|
||||
|
||||
async fn discover_nat_pmp_gateway_in_netns(
|
||||
global_ctx: ArcGlobalCtx,
|
||||
local_listener: url::Url,
|
||||
) -> anyhow::Result<(Ipv4Addr, SocketAddr)> {
|
||||
if !should_run_port_mapping_in_dedicated_thread(&global_ctx) {
|
||||
return ActiveUdpPortMapping::discover_nat_pmp_gateway(&local_listener).await;
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _g = global_ctx.net_ns.guard();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build runtime for nat-pmp gateway discovery")?
|
||||
.block_on(ActiveUdpPortMapping::discover_nat_pmp_gateway(
|
||||
&local_listener,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.context("join nat-pmp gateway discovery task")?
|
||||
}
|
||||
|
||||
async fn establish_nat_pmp_mapping_in_netns(
|
||||
global_ctx: ArcGlobalCtx,
|
||||
local_listener: url::Url,
|
||||
gateway: Ipv4Addr,
|
||||
local_addr: SocketAddr,
|
||||
) -> anyhow::Result<ActiveUdpPortMapping> {
|
||||
if !should_run_port_mapping_in_dedicated_thread(&global_ctx) {
|
||||
return ActiveUdpPortMapping::establish_via_nat_pmp(&local_listener, gateway, local_addr)
|
||||
.await;
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _g = global_ctx.net_ns.guard();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build runtime for nat-pmp mapping establishment")?
|
||||
.block_on(ActiveUdpPortMapping::establish_via_nat_pmp(
|
||||
&local_listener,
|
||||
gateway,
|
||||
local_addr,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.context("join nat-pmp mapping establishment task")?
|
||||
}
|
||||
|
||||
async fn run_udp_port_mapping_task(
|
||||
local_listener: url::Url,
|
||||
mapping: ActiveUdpPortMapping,
|
||||
mut stop_rx: oneshot::Receiver<()>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(UPNP_RENEW_INTERVAL) => {
|
||||
if let Err(err) = mapping.renew().await {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
%local_listener,
|
||||
backend = mapping.backend_name(),
|
||||
gateway_external_port = mapping.gateway_external_port,
|
||||
"failed to renew udp port mapping"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ = &mut stop_rx => break,
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = mapping.remove().await {
|
||||
tracing::debug!(
|
||||
?err,
|
||||
%local_listener,
|
||||
backend = mapping.backend_name(),
|
||||
gateway_external_port = mapping.gateway_external_port,
|
||||
"failed to remove udp port mapping"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_run_port_mapping_in_dedicated_thread(global_ctx: &ArcGlobalCtx) -> bool {
|
||||
global_ctx.net_ns.name().is_some()
|
||||
}
|
||||
|
||||
async fn add_udp_mapping_port_igd(
|
||||
gateway: &TokioGateway,
|
||||
local_addr: SocketAddr,
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<u16> {
|
||||
match gateway
|
||||
.add_any_port(
|
||||
PortMappingProtocol::UDP,
|
||||
local_addr,
|
||||
UPNP_LEASE_DURATION_SECS,
|
||||
UPNP_DESCRIPTION,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(external_port) => Ok(external_port),
|
||||
Err(AddAnyPortError::RequestError(err)) => {
|
||||
tracing::debug!(
|
||||
?err,
|
||||
%local_listener,
|
||||
gateway = %gateway.addr,
|
||||
%local_addr,
|
||||
"igd any-port udp mapping failed, retry with same-port mapping"
|
||||
);
|
||||
|
||||
gateway
|
||||
.add_port(
|
||||
PortMappingProtocol::UDP,
|
||||
local_addr.port(),
|
||||
local_addr,
|
||||
UPNP_LEASE_DURATION_SECS,
|
||||
UPNP_DESCRIPTION,
|
||||
)
|
||||
.await
|
||||
.map(|_| local_addr.port())
|
||||
.map_err(|same_port_err| {
|
||||
anyhow!(
|
||||
"igd udp mapping failed for {local_listener}: any-port error: {err}; same-port error: {same_port_err}"
|
||||
)
|
||||
})
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_udp_mapping_port_nat_pmp(
|
||||
gateway: Ipv4Addr,
|
||||
local_addr: SocketAddr,
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<u16> {
|
||||
match request_nat_pmp_mapping(gateway, local_addr.port(), 0, UPNP_LEASE_DURATION_SECS).await {
|
||||
Ok(external_port) => Ok(external_port),
|
||||
Err(any_port_err) => {
|
||||
tracing::debug!(
|
||||
?any_port_err,
|
||||
%local_listener,
|
||||
gateway = %gateway,
|
||||
%local_addr,
|
||||
"nat-pmp any-port udp mapping failed, retry with same-port mapping"
|
||||
);
|
||||
|
||||
request_nat_pmp_mapping(
|
||||
gateway,
|
||||
local_addr.port(),
|
||||
local_addr.port(),
|
||||
UPNP_LEASE_DURATION_SECS,
|
||||
)
|
||||
.await
|
||||
.map_err(|same_port_err| {
|
||||
anyhow!(
|
||||
"nat-pmp udp mapping failed for {local_listener}: any-port error: {any_port_err}; same-port error: {same_port_err}"
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_nat_pmp_mapping(
|
||||
gateway: Ipv4Addr,
|
||||
private_port: u16,
|
||||
public_port: u16,
|
||||
lifetime_secs: u32,
|
||||
) -> anyhow::Result<u16> {
|
||||
let client = new_tokio_natpmp_with(gateway)
|
||||
.await
|
||||
.with_context(|| format!("create nat-pmp client for gateway {gateway}"))?;
|
||||
client
|
||||
.send_port_mapping_request(
|
||||
NatPmpProtocol::UDP,
|
||||
private_port,
|
||||
public_port,
|
||||
lifetime_secs,
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"send nat-pmp udp mapping request private_port={private_port} public_port={public_port} gateway={gateway}"
|
||||
)
|
||||
})?;
|
||||
|
||||
let response = tokio::time::timeout(NAT_PMP_RESPONSE_TIMEOUT, client.read_response_or_retry())
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"wait nat-pmp udp mapping response private_port={private_port} gateway={gateway}"
|
||||
)
|
||||
})?
|
||||
.map_err(anyhow::Error::from)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"read nat-pmp udp mapping response private_port={private_port} gateway={gateway}"
|
||||
)
|
||||
})?;
|
||||
|
||||
match response {
|
||||
NatPmpResponse::UDP(mapping) | NatPmpResponse::TCP(mapping) => Ok(mapping.public_port()),
|
||||
NatPmpResponse::Gateway(_) => {
|
||||
bail!("unexpected nat-pmp gateway response for udp mapping request")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn renew_udp_mapping_nat_pmp(
|
||||
gateway: Ipv4Addr,
|
||||
local_addr: SocketAddr,
|
||||
external_port: u16,
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<()> {
|
||||
request_nat_pmp_mapping(
|
||||
gateway,
|
||||
local_addr.port(),
|
||||
external_port,
|
||||
UPNP_LEASE_DURATION_SECS,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.with_context(|| format!("renew udp port mapping {local_listener}"))
|
||||
}
|
||||
|
||||
async fn remove_udp_mapping_nat_pmp(
|
||||
gateway: Ipv4Addr,
|
||||
local_addr: SocketAddr,
|
||||
external_port: u16,
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<()> {
|
||||
request_nat_pmp_mapping(gateway, local_addr.port(), external_port, 0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.with_context(|| format!("remove udp port mapping {local_listener}"))
|
||||
}
|
||||
|
||||
fn should_map_udp_listener(local_listener: &url::Url) -> bool {
|
||||
if local_listener.scheme() != "udp" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(host) = listener_ipv4_host(local_listener) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if host.is_loopback() || host.is_broadcast() {
|
||||
return false;
|
||||
}
|
||||
|
||||
host.is_unspecified() || host.is_private() || host.is_link_local()
|
||||
}
|
||||
|
||||
fn listener_ipv4_host(local_listener: &url::Url) -> Option<Ipv4Addr> {
|
||||
local_listener.host_str()?.parse().ok()
|
||||
}
|
||||
|
||||
async fn resolve_internal_addr(
|
||||
gateway_addr: SocketAddr,
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<SocketAddr> {
|
||||
let port = local_listener
|
||||
.port()
|
||||
.ok_or_else(|| anyhow!("listener port is missing"))?;
|
||||
let host =
|
||||
listener_ipv4_host(local_listener).ok_or_else(|| anyhow!("listener must be ipv4"))?;
|
||||
|
||||
let ip = if host.is_unspecified() {
|
||||
let udp = std::net::UdpSocket::bind("0.0.0.0:0")
|
||||
.context("bind probe socket for gateway route")?;
|
||||
udp.connect(gateway_addr)
|
||||
.with_context(|| format!("connect probe socket to gateway {gateway_addr}"))?;
|
||||
let SocketAddr::V4(local_addr) = udp.local_addr().context("get probe socket local addr")?
|
||||
else {
|
||||
bail!("gateway route selected a non-ipv4 local address");
|
||||
};
|
||||
*local_addr.ip()
|
||||
} else {
|
||||
host
|
||||
};
|
||||
|
||||
Ok(SocketAddr::new(ip.into(), port))
|
||||
}
|
||||
|
||||
async fn renew_udp_mapping_igd(
|
||||
gateway: &TokioGateway,
|
||||
local_addr: SocketAddr,
|
||||
external_port: u16,
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<()> {
|
||||
gateway
|
||||
.add_port(
|
||||
PortMappingProtocol::UDP,
|
||||
external_port,
|
||||
local_addr,
|
||||
UPNP_LEASE_DURATION_SECS,
|
||||
UPNP_DESCRIPTION,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("renew udp port mapping {local_listener}"))
|
||||
}
|
||||
|
||||
async fn remove_udp_mapping_igd(
|
||||
gateway: &TokioGateway,
|
||||
external_port: u16,
|
||||
local_listener: &url::Url,
|
||||
) -> anyhow::Result<()> {
|
||||
gateway
|
||||
.remove_port(PortMappingProtocol::UDP, external_port)
|
||||
.await
|
||||
.with_context(|| format!("remove udp port mapping {local_listener}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn udp_mapping_requires_private_or_unspecified_ipv4_listener() {
|
||||
assert!(super::should_map_udp_listener(
|
||||
&"udp://0.0.0.0:11010".parse().unwrap()
|
||||
));
|
||||
assert!(super::should_map_udp_listener(
|
||||
&"udp://192.168.1.10:11010".parse().unwrap()
|
||||
));
|
||||
assert!(!super::should_map_udp_listener(
|
||||
&"udp://127.0.0.1:11010".parse().unwrap()
|
||||
));
|
||||
assert!(!super::should_map_udp_listener(
|
||||
&"udp://8.8.8.8:11010".parse().unwrap()
|
||||
));
|
||||
assert!(!super::should_map_udp_listener(
|
||||
&"tcp://0.0.0.0:11010".parse().unwrap()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
net::{IpAddr, Ipv6Addr, SocketAddr},
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
str::FromStr,
|
||||
sync::{
|
||||
Arc,
|
||||
@@ -27,7 +27,7 @@ use crate::{
|
||||
proto::{
|
||||
peer_rpc::{
|
||||
DirectConnectorRpc, DirectConnectorRpcClientFactory, DirectConnectorRpcServer,
|
||||
GetIpListRequest, GetIpListResponse, SendV6HolePunchPacketRequest,
|
||||
GetIpListRequest, GetIpListResponse, SendUdpHolePunchPacketRequest,
|
||||
},
|
||||
rpc_types::controller::BaseController,
|
||||
},
|
||||
@@ -51,6 +51,37 @@ pub const DIRECT_CONNECTOR_BLACKLIST_TIMEOUT_SEC: u64 = 300;
|
||||
|
||||
static TESTING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn mapped_listener_port(url: &url::Url) -> Option<u16> {
|
||||
url.port().or_else(|| {
|
||||
TunnelScheme::try_from(url)
|
||||
.ok()
|
||||
.and_then(|scheme| IpScheme::try_from(scheme).ok())
|
||||
.map(IpScheme::default_port)
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_mapped_listener_addrs(listener: &url::Url) -> Result<Vec<SocketAddr>, Error> {
|
||||
socket_addrs(listener, || mapped_listener_port(listener)).await
|
||||
}
|
||||
|
||||
fn is_usable_public_ipv6_candidate(ip: &Ipv6Addr, global_ctx: &ArcGlobalCtx) -> bool {
|
||||
is_usable_public_ipv6_candidate_with_mode(ip, global_ctx, TESTING.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn is_usable_public_ipv6_candidate_with_mode(
|
||||
ip: &Ipv6Addr,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
testing: bool,
|
||||
) -> bool {
|
||||
!global_ctx.is_ip_easytier_managed_ipv6(ip)
|
||||
&& (testing
|
||||
|| (!ip.is_loopback()
|
||||
&& !ip.is_unspecified()
|
||||
&& !ip.is_unique_local()
|
||||
&& !ip.is_unicast_link_local()
|
||||
&& !ip.is_multicast()))
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait PeerManagerForDirectConnector {
|
||||
async fn list_peers(&self) -> Vec<PeerId>;
|
||||
@@ -117,37 +148,25 @@ impl DirectConnectorManagerData {
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_send_v6_hole_punch_packet(
|
||||
async fn remote_send_udp_hole_punch_packet(
|
||||
&self,
|
||||
dst_peer_id: PeerId,
|
||||
local_socket: &UdpSocket,
|
||||
connector_addr: SocketAddr,
|
||||
remote_url: &url::Url,
|
||||
) -> Result<(), Error> {
|
||||
if !matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp)) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"udp hole punch packet only applies to udp listener: {}",
|
||||
remote_url
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let global_ctx = self.peer_manager.get_global_ctx();
|
||||
let listener_port = remote_url.port().ok_or(anyhow::anyhow!(
|
||||
let listener_port = mapped_listener_port(remote_url).ok_or(anyhow::anyhow!(
|
||||
"failed to parse port from remote url: {}",
|
||||
remote_url
|
||||
))?;
|
||||
let connector_ip = global_ctx
|
||||
.get_stun_info_collector()
|
||||
.get_stun_info()
|
||||
.public_ip
|
||||
.iter()
|
||||
.find(|x| x.contains(":"))
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"failed to get public ipv6 address from stun info"
|
||||
))?
|
||||
.parse::<std::net::Ipv6Addr>()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to parse public ipv6 address from stun info: {:?}",
|
||||
global_ctx.get_stun_info_collector().get_stun_info()
|
||||
)
|
||||
})?;
|
||||
let connector_addr = SocketAddr::new(
|
||||
std::net::IpAddr::V6(connector_ip),
|
||||
local_socket.local_addr()?.port(),
|
||||
);
|
||||
|
||||
let rpc_stub = self
|
||||
.peer_manager
|
||||
@@ -160,9 +179,9 @@ impl DirectConnectorManagerData {
|
||||
);
|
||||
|
||||
rpc_stub
|
||||
.send_v6_hole_punch_packet(
|
||||
.send_udp_hole_punch_packet(
|
||||
BaseController::default(),
|
||||
SendV6HolePunchPacketRequest {
|
||||
SendUdpHolePunchPacketRequest {
|
||||
listener_port: listener_port as u32,
|
||||
connector_addr: Some(connector_addr.into()),
|
||||
},
|
||||
@@ -170,7 +189,7 @@ impl DirectConnectorManagerData {
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"do rpc, send v6 hole punch packet to peer {} at {}",
|
||||
"do rpc, send udp hole punch packet to peer {} at {}",
|
||||
dst_peer_id, remote_url
|
||||
)
|
||||
})?;
|
||||
@@ -188,12 +207,29 @@ impl DirectConnectorManagerData {
|
||||
.await
|
||||
.with_context(|| format!("failed to bind local socket for {}", remote_url))?,
|
||||
);
|
||||
let connector_ip = self
|
||||
.global_ctx
|
||||
.get_stun_info_collector()
|
||||
.get_stun_info()
|
||||
.public_ip
|
||||
.iter()
|
||||
.filter_map(|ip| ip.parse::<Ipv6Addr>().ok())
|
||||
.find(|ip| !self.global_ctx.is_ip_easytier_managed_ipv6(ip));
|
||||
|
||||
// ask remote to send v6 hole punch packet
|
||||
// and no matter what the result is, continue to connect
|
||||
let _ = self
|
||||
.remote_send_v6_hole_punch_packet(dst_peer_id, &local_socket, remote_url)
|
||||
.await;
|
||||
if let Some(connector_ip) = connector_ip {
|
||||
let connector_addr =
|
||||
SocketAddr::new(IpAddr::V6(connector_ip), local_socket.local_addr()?.port());
|
||||
let _ = self
|
||||
.remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url)
|
||||
.await;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
?remote_url,
|
||||
"skip remote IPv6 hole-punch packet; no non-EasyTier public IPv6 in STUN info"
|
||||
);
|
||||
}
|
||||
|
||||
let udp_connector = UdpTunnelConnector::new(remote_url.clone());
|
||||
let remote_addr = SocketAddr::from_url(remote_url.clone(), IpVersion::V6).await?;
|
||||
@@ -207,14 +243,80 @@ impl DirectConnectorManagerData {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn connect_to_public_ipv4(
|
||||
&self,
|
||||
dst_peer_id: PeerId,
|
||||
remote_url: &url::Url,
|
||||
) -> Result<(PeerId, PeerConnId), Error> {
|
||||
let local_socket = {
|
||||
let _g = self.global_ctx.net_ns.guard();
|
||||
Arc::new(
|
||||
UdpSocket::bind("0.0.0.0:0")
|
||||
.await
|
||||
.with_context(|| format!("failed to bind local socket for {}", remote_url))?,
|
||||
)
|
||||
};
|
||||
let connector_addr = self
|
||||
.peer_manager
|
||||
.get_global_ctx()
|
||||
.get_stun_info_collector()
|
||||
.get_udp_port_mapping_with_socket(local_socket.clone())
|
||||
.await
|
||||
.with_context(|| format!("failed to get udp port mapping for {}", remote_url))?;
|
||||
|
||||
let _ = self
|
||||
.remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url)
|
||||
.await;
|
||||
|
||||
let udp_connector = UdpTunnelConnector::new(remote_url.clone());
|
||||
let remote_addr = SocketAddr::from_url(remote_url.clone(), IpVersion::V4).await?;
|
||||
let ret = udp_connector
|
||||
.try_connect_with_socket(local_socket, remote_addr)
|
||||
.await?;
|
||||
|
||||
self.peer_manager
|
||||
.add_client_tunnel_with_peer_id_hint(ret, true, Some(dst_peer_id))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn do_try_connect_to_ip(&self, dst_peer_id: PeerId, addr: String) -> Result<(), Error> {
|
||||
let connector = create_connector_by_url(&addr, &self.global_ctx, IpVersion::Both).await?;
|
||||
let remote_url = connector.remote_url();
|
||||
let (peer_id, conn_id) = if matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp))
|
||||
&& matches!(remote_url.host(), Some(Host::Ipv6(_)))
|
||||
{
|
||||
self.connect_to_public_ipv6(dst_peer_id, &remote_url)
|
||||
.await?
|
||||
let (peer_id, conn_id) = if matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp)) {
|
||||
match remote_url.host() {
|
||||
Some(Host::Ipv6(_)) => {
|
||||
self.connect_to_public_ipv6(dst_peer_id, &remote_url)
|
||||
.await?
|
||||
}
|
||||
Some(Host::Ipv4(ip)) if is_public_ipv4(ip) => {
|
||||
match self.connect_to_public_ipv4(dst_peer_id, &remote_url).await {
|
||||
Ok(ret) => ret,
|
||||
Err(err) => {
|
||||
tracing::debug!(
|
||||
?err,
|
||||
%remote_url,
|
||||
"udp public ipv4 listener punch failed, falling back to direct connect"
|
||||
);
|
||||
timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
self.peer_manager.try_direct_connect_with_peer_id_hint(
|
||||
connector,
|
||||
Some(dst_peer_id),
|
||||
),
|
||||
)
|
||||
.await??
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
self.peer_manager
|
||||
.try_direct_connect_with_peer_id_hint(connector, Some(dst_peer_id)),
|
||||
)
|
||||
.await??
|
||||
}
|
||||
}
|
||||
} else {
|
||||
timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
@@ -305,7 +407,7 @@ impl DirectConnectorManagerData {
|
||||
listener: &url::Url,
|
||||
tasks: &mut JoinSet<Result<(), Error>>,
|
||||
) {
|
||||
let Ok(mut addrs) = socket_addrs(listener, || None).await else {
|
||||
let Ok(mut addrs) = resolve_mapped_listener_addrs(listener).await else {
|
||||
tracing::error!(?listener, "failed to parse socket address from listener");
|
||||
return;
|
||||
};
|
||||
@@ -389,14 +491,7 @@ impl DirectConnectorManagerData {
|
||||
.iter()
|
||||
.chain(ip_list.public_ipv6.iter())
|
||||
.filter_map(|x| Ipv6Addr::from_str(&x.to_string()).ok())
|
||||
.filter(|x| {
|
||||
TESTING.load(Ordering::Relaxed)
|
||||
|| (!x.is_loopback()
|
||||
&& !x.is_unspecified()
|
||||
&& !x.is_unique_local()
|
||||
&& !x.is_unicast_link_local()
|
||||
&& !x.is_multicast())
|
||||
})
|
||||
.filter(|x| is_usable_public_ipv6_candidate(x, &self.global_ctx))
|
||||
.collect::<HashSet<_>>()
|
||||
.iter()
|
||||
.for_each(|ip| {
|
||||
@@ -425,6 +520,11 @@ impl DirectConnectorManagerData {
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if self.global_ctx.is_ip_easytier_managed_ipv6(s_addr.ip()) {
|
||||
tracing::debug!(
|
||||
?listener,
|
||||
"skip EasyTier-managed IPv6 as direct-connect target"
|
||||
);
|
||||
} else if !s_addr.ip().is_loopback() || TESTING.load(Ordering::Relaxed) {
|
||||
if self
|
||||
.global_ctx
|
||||
@@ -459,7 +559,7 @@ impl DirectConnectorManagerData {
|
||||
.into_iter()
|
||||
.map(Into::<url::Url>::into)
|
||||
.filter_map(|l| if l.scheme() != "ring" { Some(l) } else { None })
|
||||
.filter(|l| l.port().is_some() && l.host().is_some())
|
||||
.filter(|l| mapped_listener_port(l).is_some() && l.host().is_some())
|
||||
.filter(|l| enable_ipv6 || !matches!(l.host().unwrap().to_owned(), Host::Ipv6(_)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -577,6 +677,14 @@ impl DirectConnectorManagerData {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
|
||||
!ip.is_private()
|
||||
&& !ip.is_loopback()
|
||||
&& !ip.is_link_local()
|
||||
&& !ip.is_broadcast()
|
||||
&& !ip.is_unspecified()
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DirectConnectorManagerData {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DirectConnectorManagerData")
|
||||
@@ -677,13 +785,25 @@ impl DirectConnectorManager {
|
||||
pub fn run_as_client(&mut self) {
|
||||
self.client.start();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn try_direct_connect_with_ip_list(
|
||||
&self,
|
||||
dst_peer_id: PeerId,
|
||||
ip_list: GetIpListResponse,
|
||||
) -> Result<(), Error> {
|
||||
self.data
|
||||
.do_try_direct_connect_internal(dst_peer_id, ip_list)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
common::global_ctx::tests::get_mock_global_ctx,
|
||||
connector::direct::{
|
||||
DirectConnectorManager, DirectConnectorManagerData, DstListenerUrlBlackListItem,
|
||||
},
|
||||
@@ -693,12 +813,84 @@ mod tests {
|
||||
wait_route_appear_with_cost,
|
||||
},
|
||||
proto::peer_rpc::GetIpListResponse,
|
||||
tunnel::{IpScheme, TunnelScheme, matches_scheme},
|
||||
};
|
||||
|
||||
use super::TESTING;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
use super::{TESTING, mapped_listener_port, resolve_mapped_listener_addrs};
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_connector_mapped_listener() {
|
||||
async fn public_ipv6_candidate_rejects_easytier_managed_addr_even_in_tests() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
let managed_ipv6: cidr::Ipv6Inet = "2001:db8::2/128".parse().unwrap();
|
||||
global_ctx.set_public_ipv6_routes(BTreeSet::from([managed_ipv6]));
|
||||
|
||||
assert!(!super::is_usable_public_ipv6_candidate_with_mode(
|
||||
&"2001:db8::2".parse().unwrap(),
|
||||
&global_ctx,
|
||||
true,
|
||||
));
|
||||
assert!(super::is_usable_public_ipv6_candidate_with_mode(
|
||||
&"::1".parse().unwrap(),
|
||||
&global_ctx,
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_ipv6_url_matches_hole_punch_branch_condition() {
|
||||
let remote_url: url::Url = "udp://[2001:db8::1]:11010".parse().unwrap();
|
||||
let takes_udp_ipv6_hole_punch_branch =
|
||||
matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp))
|
||||
&& matches!(remote_url.host(), Some(url::Host::Ipv6(_)));
|
||||
|
||||
assert!(takes_udp_ipv6_hole_punch_branch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_listener_port_uses_ip_scheme_defaults() {
|
||||
assert_eq!(
|
||||
mapped_listener_port(&"ws://example.com".parse().unwrap()),
|
||||
Some(80)
|
||||
);
|
||||
assert_eq!(
|
||||
mapped_listener_port(&"wss://example.com".parse().unwrap()),
|
||||
Some(443)
|
||||
);
|
||||
assert_eq!(
|
||||
mapped_listener_port(&"tcp://127.0.0.1".parse().unwrap()),
|
||||
Some(11010)
|
||||
);
|
||||
assert_eq!(
|
||||
mapped_listener_port(&"udp://127.0.0.1".parse().unwrap()),
|
||||
Some(11010)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_mapped_listener_addrs_uses_default_ports() {
|
||||
let wss_addrs = resolve_mapped_listener_addrs(&"wss://127.0.0.1".parse().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
wss_addrs,
|
||||
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443)]
|
||||
);
|
||||
|
||||
let tcp_addrs = resolve_mapped_listener_addrs(&"tcp://127.0.0.1".parse().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tcp_addrs,
|
||||
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 11010)]
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_direct_connector_mapped_listener_test(
|
||||
mapped_listener: &str,
|
||||
target_listener: &str,
|
||||
) {
|
||||
TESTING.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let p_a = create_mock_peer_manager().await;
|
||||
let p_b = create_mock_peer_manager().await;
|
||||
@@ -717,11 +909,11 @@ mod tests {
|
||||
|
||||
p_c.get_global_ctx()
|
||||
.config
|
||||
.set_mapped_listeners(Some(vec!["tcp://127.0.0.1:11334".parse().unwrap()]));
|
||||
.set_mapped_listeners(Some(vec![mapped_listener.parse().unwrap()]));
|
||||
|
||||
p_x.get_global_ctx()
|
||||
.config
|
||||
.set_listeners(vec!["tcp://0.0.0.0:11334".parse().unwrap()]);
|
||||
.set_listeners(vec![target_listener.parse().unwrap()]);
|
||||
let mut lis_x = ListenerManager::new(p_x.get_global_ctx(), p_x.clone());
|
||||
lis_x.prepare_listeners().await.unwrap();
|
||||
lis_x.run().await.unwrap();
|
||||
@@ -738,6 +930,12 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_connector_mapped_listener() {
|
||||
run_direct_connector_mapped_listener_test("tcp://127.0.0.1:11334", "tcp://0.0.0.0:11334")
|
||||
.await;
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[tokio::test]
|
||||
async fn direct_connector_basic_test(
|
||||
|
||||
+180
-15
@@ -1,19 +1,17 @@
|
||||
use std::{
|
||||
net::{SocketAddr, SocketAddrV4, SocketAddrV6},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
|
||||
|
||||
use crate::{
|
||||
common::{error::Error, global_ctx::ArcGlobalCtx, idn, network::IPCollector},
|
||||
common::{dns::socket_addrs, error::Error, global_ctx::ArcGlobalCtx, idn},
|
||||
connector::dns_connector::DnsTunnelConnector,
|
||||
proto::common::PeerFeatureFlag,
|
||||
tunnel::{
|
||||
self, FromUrl, IpScheme, IpVersion, TunnelConnector, TunnelError, TunnelScheme,
|
||||
self, IpScheme, IpVersion, TunnelConnector, TunnelError, TunnelScheme,
|
||||
ring::RingTunnelConnector, tcp::TcpTunnelConnector, udp::UdpTunnelConnector,
|
||||
},
|
||||
utils::BoxExt,
|
||||
};
|
||||
use http_connector::HttpTunnelConnector;
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
pub mod direct;
|
||||
pub mod manual;
|
||||
@@ -56,7 +54,7 @@ pub(crate) fn should_background_p2p_with_peer(
|
||||
async fn set_bind_addr_for_peer_connector(
|
||||
connector: &mut (impl TunnelConnector + ?Sized),
|
||||
is_ipv4: bool,
|
||||
ip_collector: &Arc<IPCollector>,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
) {
|
||||
if cfg!(any(
|
||||
target_os = "android",
|
||||
@@ -69,7 +67,7 @@ async fn set_bind_addr_for_peer_connector(
|
||||
return;
|
||||
}
|
||||
|
||||
let ips = ip_collector.collect_ip_addrs().await;
|
||||
let ips = global_ctx.get_ip_collector().collect_ip_addrs().await;
|
||||
if is_ipv4 {
|
||||
let mut bind_addrs = vec![];
|
||||
for ipv4 in ips.interface_ipv4s {
|
||||
@@ -80,7 +78,11 @@ async fn set_bind_addr_for_peer_connector(
|
||||
} else {
|
||||
let mut bind_addrs = vec![];
|
||||
for ipv6 in ips.interface_ipv6s.iter().chain(ips.public_ipv6.iter()) {
|
||||
let socket_addr = SocketAddrV6::new(std::net::Ipv6Addr::from(*ipv6), 0, 0, 0).into();
|
||||
let ipv6 = std::net::Ipv6Addr::from(*ipv6);
|
||||
if global_ctx.is_ip_easytier_managed_ipv6(&ipv6) {
|
||||
continue;
|
||||
}
|
||||
let socket_addr = SocketAddrV6::new(ipv6, 0, 0, 0).into();
|
||||
bind_addrs.push(socket_addr);
|
||||
}
|
||||
connector.set_bind_addrs(bind_addrs);
|
||||
@@ -88,6 +90,144 @@ async fn set_bind_addr_for_peer_connector(
|
||||
let _ = connector;
|
||||
}
|
||||
|
||||
struct ResolvedConnectorAddr {
|
||||
addr: SocketAddr,
|
||||
ip_version: IpVersion,
|
||||
}
|
||||
|
||||
fn connector_default_port(url: &url::Url) -> Option<u16> {
|
||||
url.try_into()
|
||||
.ok()
|
||||
.and_then(|s: TunnelScheme| s.try_into().ok())
|
||||
.map(IpScheme::default_port)
|
||||
}
|
||||
|
||||
fn addr_matches_ip_version(addr: &SocketAddr, ip_version: IpVersion) -> bool {
|
||||
match ip_version {
|
||||
IpVersion::V4 => addr.is_ipv4(),
|
||||
IpVersion::V6 => addr.is_ipv6(),
|
||||
IpVersion::Both => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_effective_ip_version(addrs: &[SocketAddr], requested_ip_version: IpVersion) -> IpVersion {
|
||||
match requested_ip_version {
|
||||
IpVersion::Both if addrs.iter().all(SocketAddr::is_ipv4) => IpVersion::V4,
|
||||
IpVersion::Both if addrs.iter().all(SocketAddr::is_ipv6) => IpVersion::V6,
|
||||
_ => requested_ip_version,
|
||||
}
|
||||
}
|
||||
|
||||
async fn easytier_managed_ipv6_source_for_dst(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
dst_addr: SocketAddrV6,
|
||||
) -> Result<Option<Ipv6Addr>, Error> {
|
||||
let socket = {
|
||||
let _g = global_ctx.net_ns.guard();
|
||||
tokio::net::UdpSocket::bind("[::]:0").await?
|
||||
};
|
||||
socket.connect(SocketAddr::V6(dst_addr)).await?;
|
||||
|
||||
let IpAddr::V6(local_ip) = socket.local_addr()?.ip() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(global_ctx
|
||||
.is_ip_easytier_managed_ipv6(&local_ip)
|
||||
.then_some(local_ip))
|
||||
}
|
||||
|
||||
async fn ipv6_connector_reject_reason(
|
||||
url: &url::Url,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
v6_addr: SocketAddrV6,
|
||||
skip_source_validation_errors: bool,
|
||||
) -> Result<Option<String>, Error> {
|
||||
if global_ctx.is_ip_easytier_managed_ipv6(v6_addr.ip()) {
|
||||
return Ok(Some(format!(
|
||||
"{} resolves to EasyTier-managed IPv6 {}",
|
||||
url,
|
||||
v6_addr.ip()
|
||||
)));
|
||||
}
|
||||
|
||||
match easytier_managed_ipv6_source_for_dst(global_ctx, v6_addr).await {
|
||||
Ok(Some(local_ip)) => Ok(Some(format!(
|
||||
"{} would use EasyTier-managed IPv6 {} as local source for {}",
|
||||
url, local_ip, v6_addr
|
||||
))),
|
||||
Ok(None) => Ok(None),
|
||||
Err(err) if skip_source_validation_errors => Ok(Some(format!(
|
||||
"{} IPv6 candidate {} could not be validated: {}",
|
||||
url, v6_addr, err
|
||||
))),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_connector_socket_addr(
|
||||
url: &url::Url,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
ip_version: IpVersion,
|
||||
) -> Result<ResolvedConnectorAddr, Error> {
|
||||
let addrs = socket_addrs(url, || connector_default_port(url))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
TunnelError::InvalidAddr(format!(
|
||||
"failed to resolve socket addr, url: {}, error: {}",
|
||||
url, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut usable_addrs = Vec::new();
|
||||
let mut rejected_ipv6_reason = None;
|
||||
let skip_source_validation_errors = ip_version == IpVersion::Both;
|
||||
for addr in addrs
|
||||
.into_iter()
|
||||
.filter(|addr| addr_matches_ip_version(addr, ip_version))
|
||||
{
|
||||
if let SocketAddr::V6(v6_addr) = addr
|
||||
&& let Some(reason) = ipv6_connector_reject_reason(
|
||||
url,
|
||||
global_ctx,
|
||||
v6_addr,
|
||||
skip_source_validation_errors,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
rejected_ipv6_reason = Some(reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
usable_addrs.push(addr);
|
||||
}
|
||||
|
||||
if usable_addrs.is_empty() {
|
||||
if let Some(reason) = rejected_ipv6_reason {
|
||||
return Err(Error::InvalidUrl(format!(
|
||||
"{}, refusing overlay-backed underlay connection",
|
||||
reason
|
||||
)));
|
||||
}
|
||||
|
||||
return Err(Error::TunnelError(TunnelError::NoDnsRecordFound(
|
||||
ip_version,
|
||||
)));
|
||||
}
|
||||
|
||||
let effective_ip_version = infer_effective_ip_version(&usable_addrs, ip_version);
|
||||
|
||||
let addr = usable_addrs
|
||||
.choose(&mut rand::thread_rng())
|
||||
.copied()
|
||||
.ok_or_else(|| Error::TunnelError(TunnelError::NoDnsRecordFound(ip_version)))?;
|
||||
|
||||
Ok(ResolvedConnectorAddr {
|
||||
addr,
|
||||
ip_version: effective_ip_version,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_connector_by_url(
|
||||
url: &str,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
@@ -98,9 +238,11 @@ pub async fn create_connector_by_url(
|
||||
let scheme = (&url)
|
||||
.try_into()
|
||||
.map_err(|_| TunnelError::InvalidProtocol(url.scheme().to_owned()))?;
|
||||
let mut effective_connector_ip_version = ip_version;
|
||||
let mut connector: Box<dyn TunnelConnector + 'static> = match scheme {
|
||||
TunnelScheme::Ip(scheme) => {
|
||||
let dst_addr = SocketAddr::from_url(url.clone(), ip_version).await?;
|
||||
let resolved_addr = resolve_connector_socket_addr(&url, global_ctx, ip_version).await?;
|
||||
effective_connector_ip_version = resolved_addr.ip_version;
|
||||
let mut connector: Box<dyn TunnelConnector> = match scheme {
|
||||
IpScheme::Tcp => TcpTunnelConnector::new(url).boxed(),
|
||||
IpScheme::Udp => UdpTunnelConnector::new(url).boxed(),
|
||||
@@ -125,11 +267,12 @@ pub async fn create_connector_by_url(
|
||||
#[cfg(feature = "faketcp")]
|
||||
IpScheme::FakeTcp => tunnel::fake_tcp::FakeTcpTunnelConnector::new(url).boxed(),
|
||||
};
|
||||
connector.set_resolved_addr(resolved_addr.addr);
|
||||
if global_ctx.config.get_flags().bind_device {
|
||||
set_bind_addr_for_peer_connector(
|
||||
&mut connector,
|
||||
dst_addr.is_ipv4(),
|
||||
&global_ctx.get_ip_collector(),
|
||||
resolved_addr.addr.is_ipv4(),
|
||||
global_ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -151,16 +294,38 @@ pub async fn create_connector_by_url(
|
||||
DnsTunnelConnector::new(url, global_ctx.clone()).boxed()
|
||||
}
|
||||
};
|
||||
connector.set_ip_version(ip_version);
|
||||
connector.set_ip_version(effective_connector_ip_version);
|
||||
|
||||
Ok(connector)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::proto::common::PeerFeatureFlag;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::{should_background_p2p_with_peer, should_try_p2p_with_peer};
|
||||
use crate::{
|
||||
common::global_ctx::tests::get_mock_global_ctx, proto::common::PeerFeatureFlag,
|
||||
tunnel::IpVersion,
|
||||
};
|
||||
|
||||
use super::{
|
||||
create_connector_by_url, should_background_p2p_with_peer, should_try_p2p_with_peer,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn connector_rejects_easytier_managed_ipv6_destination() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
let public_route: cidr::Ipv6Inet = "2001:db8::2/128".parse().unwrap();
|
||||
global_ctx.set_public_ipv6_routes(BTreeSet::from([public_route]));
|
||||
|
||||
let ret =
|
||||
create_connector_by_url("tcp://[2001:db8::2]:11010", &global_ctx, IpVersion::V6).await;
|
||||
|
||||
assert!(matches!(
|
||||
ret,
|
||||
Err(crate::common::error::Error::InvalidUrl(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_background_p2p_requires_need_p2p() {
|
||||
|
||||
@@ -621,6 +621,13 @@ mod tests {
|
||||
Ok(format!("127.0.0.1:{}", port).parse().unwrap())
|
||||
}
|
||||
|
||||
async fn get_udp_port_mapping_with_socket(
|
||||
&self,
|
||||
udp: std::sync::Arc<tokio::net::UdpSocket>,
|
||||
) -> Result<SocketAddr, Error> {
|
||||
self.get_udp_port_mapping(udp.local_addr()?.port()).await
|
||||
}
|
||||
|
||||
async fn get_tcp_port_mapping(&self, mut port: u16) -> Result<SocketAddr, Error> {
|
||||
if port == 0 {
|
||||
port = 40144;
|
||||
|
||||
@@ -6,9 +6,10 @@ use std::{
|
||||
|
||||
use anyhow::Context;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use crate::{
|
||||
common::{PeerId, scoped_task::ScopedTask, stun::StunInfoCollectorTrait},
|
||||
common::{PeerId, stun::StunInfoCollectorTrait},
|
||||
connector::udp_hole_punch::common::{
|
||||
HOLE_PUNCH_PACKET_BODY_LEN, UdpHolePunchListener, try_connect_with_socket,
|
||||
},
|
||||
@@ -32,7 +33,7 @@ const REMOTE_WAIT_TIME_MS: u64 = 5000;
|
||||
|
||||
pub(crate) struct PunchBothEasySymHoleServer {
|
||||
common: Arc<PunchHoleServerCommon>,
|
||||
task: Mutex<Option<ScopedTask<()>>>,
|
||||
task: Mutex<Option<AbortOnDropHandle<()>>>,
|
||||
}
|
||||
|
||||
impl PunchBothEasySymHoleServer {
|
||||
@@ -161,7 +162,7 @@ impl PunchBothEasySymHoleServer {
|
||||
}
|
||||
});
|
||||
|
||||
*locked_task = Some(task.into());
|
||||
*locked_task = Some(AbortOnDropHandle::new(task));
|
||||
return Ok(SendPunchPacketBothEasySymResponse {
|
||||
is_busy: false,
|
||||
base_mapped_addr: Some(cur_mapped_addr.into()),
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use guarden::defer;
|
||||
use rand::seq::SliceRandom as _;
|
||||
use tokio::{net::UdpSocket, sync::Mutex, task::JoinSet};
|
||||
use tracing::{Instrument, Level, instrument};
|
||||
@@ -13,10 +14,8 @@ use zerocopy::FromBytes as _;
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
PeerId, error::Error, global_ctx::ArcGlobalCtx, join_joinset_background, netns::NetNS,
|
||||
stun::StunInfoCollectorTrait as _,
|
||||
PeerId, error::Error, global_ctx::ArcGlobalCtx, join_joinset_background, netns::NetNS, upnp,
|
||||
},
|
||||
defer,
|
||||
peers::peer_manager::PeerManager,
|
||||
proto::common::NatType,
|
||||
tunnel::{
|
||||
@@ -27,6 +26,7 @@ use crate::{
|
||||
};
|
||||
|
||||
pub(crate) const HOLE_PUNCH_PACKET_BODY_LEN: u16 = 16;
|
||||
const MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS: usize = 4;
|
||||
|
||||
fn generate_shuffled_port_vec() -> Vec<u16> {
|
||||
let mut rng = rand::thread_rng();
|
||||
@@ -352,6 +352,8 @@ pub(crate) struct UdpHolePunchListener {
|
||||
tasks: JoinSet<()>,
|
||||
running: Arc<AtomicCell<bool>>,
|
||||
mapped_addr: SocketAddr,
|
||||
has_port_mapping_lease: bool,
|
||||
_port_mapping_lease: Option<upnp::UdpPortMappingLease>,
|
||||
conn_counter: Arc<Box<dyn TunnelConnCounter>>,
|
||||
|
||||
listen_time: std::time::Instant,
|
||||
@@ -360,11 +362,6 @@ pub(crate) struct UdpHolePunchListener {
|
||||
}
|
||||
|
||||
impl UdpHolePunchListener {
|
||||
async fn get_avail_port() -> Result<u16, Error> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
Ok(socket.local_addr()?.port())
|
||||
}
|
||||
|
||||
#[instrument(err)]
|
||||
pub async fn new(peer_mgr: Arc<PeerManager>) -> Result<Self, Error> {
|
||||
Self::new_ext(peer_mgr, true, None).await
|
||||
@@ -376,18 +373,24 @@ impl UdpHolePunchListener {
|
||||
with_mapped_addr: bool,
|
||||
port: Option<u16>,
|
||||
) -> Result<Self, Error> {
|
||||
let port = port.unwrap_or(Self::get_avail_port().await?);
|
||||
let listen_url = format!("udp://0.0.0.0:{}", port);
|
||||
let socket = {
|
||||
let _g = peer_mgr.get_global_ctx().net_ns.guard();
|
||||
Arc::new(UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port.unwrap_or(0))).await?)
|
||||
};
|
||||
let local_port = socket.local_addr()?.port();
|
||||
let listen_url: url::Url = format!("udp://0.0.0.0:{local_port}").parse().unwrap();
|
||||
|
||||
let mapped_addr = if with_mapped_addr {
|
||||
let gctx = peer_mgr.get_global_ctx();
|
||||
let stun_info_collect = gctx.get_stun_info_collector();
|
||||
stun_info_collect.get_udp_port_mapping(port).await?
|
||||
let (mapped_addr, port_mapping_lease) = if with_mapped_addr {
|
||||
upnp::resolve_udp_public_addr(peer_mgr.get_global_ctx(), &listen_url, socket.clone())
|
||||
.await?
|
||||
} else {
|
||||
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), port))
|
||||
(
|
||||
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, local_port)),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let mut listener = UdpTunnelListener::new(listen_url.parse().unwrap());
|
||||
let mut listener = UdpTunnelListener::new_with_socket(listen_url, socket.clone());
|
||||
|
||||
{
|
||||
let _g = peer_mgr.get_global_ctx().net_ns.guard();
|
||||
@@ -437,6 +440,8 @@ impl UdpHolePunchListener {
|
||||
socket,
|
||||
running,
|
||||
mapped_addr,
|
||||
has_port_mapping_lease: port_mapping_lease.is_some(),
|
||||
_port_mapping_lease: port_mapping_lease,
|
||||
conn_counter,
|
||||
|
||||
listen_time: std::time::Instant::now(),
|
||||
@@ -517,45 +522,87 @@ impl PunchHoleServerCommon {
|
||||
pub(crate) async fn select_listener(
|
||||
&self,
|
||||
use_new_listener: bool,
|
||||
prefer_port_mapping: bool,
|
||||
) -> Option<(Arc<UdpSocket>, SocketAddr)> {
|
||||
let all_listener_sockets = &self.listeners;
|
||||
|
||||
let mut use_last = false;
|
||||
if all_listener_sockets.lock().await.len() < 16 || use_new_listener {
|
||||
tracing::warn!("creating new udp hole punching listener");
|
||||
all_listener_sockets.lock().await.push(
|
||||
UdpHolePunchListener::new(self.peer_mgr.clone())
|
||||
.await
|
||||
.ok()?,
|
||||
);
|
||||
use_last = true;
|
||||
}
|
||||
|
||||
let mut locked = all_listener_sockets.lock().await;
|
||||
|
||||
let listener = if use_last {
|
||||
Some(locked.last_mut()?)
|
||||
} else {
|
||||
// use the listener that is active most recently
|
||||
locked
|
||||
.iter_mut()
|
||||
.filter(|l| !l.mapped_addr.ip().is_unspecified())
|
||||
.max_by_key(|listener| listener.last_active_time.load())
|
||||
let (listener_count, has_reusable_listener, has_port_mapping_listener) = {
|
||||
let locked = self.listeners.lock().await;
|
||||
(
|
||||
locked.len(),
|
||||
locked.iter().any(can_reuse_public_listener),
|
||||
locked.iter().any(can_reuse_port_mapping_listener),
|
||||
)
|
||||
};
|
||||
let should_create = should_create_public_listener(
|
||||
listener_count,
|
||||
has_reusable_listener,
|
||||
has_port_mapping_listener,
|
||||
use_new_listener,
|
||||
prefer_port_mapping,
|
||||
);
|
||||
|
||||
if listener.is_none() || listener.as_ref().unwrap().mapped_addr.ip().is_unspecified() {
|
||||
if should_create {
|
||||
tracing::warn!(
|
||||
?use_new_listener,
|
||||
"no available udp hole punching listener with mapped address"
|
||||
max_listeners = MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
|
||||
"creating udp hole punching listener"
|
||||
);
|
||||
if !use_new_listener {
|
||||
return self.select_listener(true).await;
|
||||
} else {
|
||||
return None;
|
||||
match UdpHolePunchListener::new(self.peer_mgr.clone()).await {
|
||||
Ok(listener) => self.listeners.lock().await.push(listener),
|
||||
Err(err) => {
|
||||
tracing::warn!(?err, "failed to create udp hole punching listener");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let listener = listener.unwrap();
|
||||
let mut locked = self.listeners.lock().await;
|
||||
let listener_count = locked.len();
|
||||
let listener_idx = if prefer_port_mapping {
|
||||
select_reusable_port_mapping_listener_idx(locked.as_slice())
|
||||
.or_else(|| {
|
||||
if should_create && locked.last().is_some_and(can_reuse_public_listener) {
|
||||
Some(locked.len() - 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| select_reusable_public_listener_idx(locked.as_slice()))
|
||||
} else if should_create {
|
||||
locked.len().checked_sub(1)
|
||||
} else {
|
||||
select_reusable_public_listener_idx(locked.as_slice())
|
||||
};
|
||||
|
||||
let Some(listener_idx) = listener_idx else {
|
||||
tracing::warn!(
|
||||
?use_new_listener,
|
||||
?prefer_port_mapping,
|
||||
listener_count,
|
||||
max_listeners = MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
|
||||
"no available udp hole punching listener with mapped address"
|
||||
);
|
||||
if should_retry_public_listener_selection(
|
||||
use_new_listener,
|
||||
listener_count,
|
||||
prefer_port_mapping,
|
||||
has_port_mapping_listener,
|
||||
) {
|
||||
drop(locked);
|
||||
return self.select_listener(true, prefer_port_mapping).await;
|
||||
}
|
||||
return None;
|
||||
};
|
||||
|
||||
let listener = &mut locked[listener_idx];
|
||||
if !can_reuse_public_listener(listener) {
|
||||
tracing::warn!(
|
||||
?use_new_listener,
|
||||
?prefer_port_mapping,
|
||||
listener_count,
|
||||
max_listeners = MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
|
||||
"selected udp hole punching listener is not reusable"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((listener.get_socket().await, listener.mapped_addr))
|
||||
}
|
||||
|
||||
@@ -572,7 +619,73 @@ impl PunchHoleServerCommon {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(err, ret(level=Level::DEBUG), skip(ports))]
|
||||
fn can_reuse_public_listener(listener: &UdpHolePunchListener) -> bool {
|
||||
listener.running.load() && !listener.mapped_addr.ip().is_unspecified()
|
||||
}
|
||||
|
||||
fn can_reuse_port_mapping_listener(listener: &UdpHolePunchListener) -> bool {
|
||||
can_reuse_public_listener(listener) && listener.has_port_mapping_lease
|
||||
}
|
||||
|
||||
fn select_reusable_public_listener_idx(listeners: &[UdpHolePunchListener]) -> Option<usize> {
|
||||
// Reuse the listener that was active most recently.
|
||||
listeners
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, listener)| can_reuse_public_listener(listener))
|
||||
.max_by_key(|(_, listener)| listener.last_active_time.load())
|
||||
.map(|(idx, _)| idx)
|
||||
}
|
||||
|
||||
fn select_reusable_port_mapping_listener_idx(listeners: &[UdpHolePunchListener]) -> Option<usize> {
|
||||
listeners
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, listener)| can_reuse_port_mapping_listener(listener))
|
||||
.max_by_key(|(_, listener)| listener.last_active_time.load())
|
||||
.map(|(idx, _)| idx)
|
||||
}
|
||||
|
||||
fn should_create_public_listener(
|
||||
current_listener_count: usize,
|
||||
has_reusable_listener: bool,
|
||||
has_port_mapping_listener: bool,
|
||||
force_new_listener: bool,
|
||||
prefer_port_mapping: bool,
|
||||
) -> bool {
|
||||
if current_listener_count >= MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS {
|
||||
return false;
|
||||
}
|
||||
|
||||
if current_listener_count == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
if force_new_listener {
|
||||
return true;
|
||||
}
|
||||
|
||||
if prefer_port_mapping && !has_port_mapping_listener {
|
||||
return true;
|
||||
}
|
||||
|
||||
!has_reusable_listener
|
||||
}
|
||||
|
||||
fn should_retry_public_listener_selection(
|
||||
force_new_listener: bool,
|
||||
current_listener_count: usize,
|
||||
prefer_port_mapping: bool,
|
||||
has_port_mapping_listener: bool,
|
||||
) -> bool {
|
||||
if prefer_port_mapping && has_port_mapping_listener {
|
||||
return false;
|
||||
}
|
||||
|
||||
!force_new_listener && current_listener_count < MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS
|
||||
}
|
||||
|
||||
#[tracing::instrument(err, ret(level=Level::DEBUG))]
|
||||
pub(crate) async fn send_symmetric_hole_punch_packet(
|
||||
ports: &[u16],
|
||||
udp: Arc<UdpSocket>,
|
||||
@@ -606,25 +719,31 @@ async fn check_udp_socket_local_addr(
|
||||
) -> Result<(), Error> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.connect(remote_mapped_addr).await?;
|
||||
if let Ok(local_addr) = socket.local_addr() {
|
||||
// local_addr should not be equal to virtual ipv4 or virtual ipv6
|
||||
match local_addr.ip() {
|
||||
IpAddr::V4(ip) => {
|
||||
if global_ctx.get_ipv4().map(|ip| ip.address()) == Some(ip) {
|
||||
return Err(anyhow::anyhow!("local address is virtual ipv4").into());
|
||||
}
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
if global_ctx.get_ipv6().map(|ip| ip.address()) == Some(ip) {
|
||||
return Err(anyhow::anyhow!("local address is virtual ipv6").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(local_addr) = socket.local_addr()
|
||||
&& let Some(err) = easytier_managed_local_addr_error(&global_ctx, local_addr)
|
||||
{
|
||||
return Err(anyhow::anyhow!(err).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn easytier_managed_local_addr_error(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
local_addr: SocketAddr,
|
||||
) -> Option<&'static str> {
|
||||
// local_addr should not be equal to an EasyTier-managed virtual/public address.
|
||||
match local_addr.ip() {
|
||||
IpAddr::V4(ip) if global_ctx.get_ipv4().map(|ip| ip.address()) == Some(ip) => {
|
||||
Some("local address is virtual ipv4")
|
||||
}
|
||||
IpAddr::V6(ip) if global_ctx.is_ip_easytier_managed_ipv6(&ip) => {
|
||||
Some("local address is easytier-managed ipv6")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn try_connect_with_socket(
|
||||
global_ctx: ArcGlobalCtx,
|
||||
socket: Arc<UdpSocket>,
|
||||
@@ -647,3 +766,84 @@ pub(crate) async fn try_connect_with_socket(
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::BTreeSet, net::SocketAddr};
|
||||
|
||||
use crate::common::global_ctx::tests::get_mock_global_ctx;
|
||||
|
||||
use super::{
|
||||
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, easytier_managed_local_addr_error,
|
||||
should_create_public_listener, should_retry_public_listener_selection,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_addr_check_rejects_easytier_public_ipv6_route() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
let public_route: cidr::Ipv6Inet = "2001:db8::4/128".parse().unwrap();
|
||||
global_ctx.set_public_ipv6_routes(BTreeSet::from([public_route]));
|
||||
|
||||
let local_addr: SocketAddr = "[2001:db8::4]:1234".parse().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
easytier_managed_local_addr_error(&global_ctx, local_addr),
|
||||
Some("local address is easytier-managed ipv6")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_selection_prefers_reuse_before_cap() {
|
||||
assert!(!should_create_public_listener(1, true, true, false, false));
|
||||
assert!(!should_create_public_listener(
|
||||
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_selection_creates_when_empty_or_no_reusable_listener() {
|
||||
assert!(should_create_public_listener(0, false, false, false, false));
|
||||
assert!(should_create_public_listener(1, false, false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_selection_force_new_respects_cap() {
|
||||
assert!(should_create_public_listener(1, true, true, true, false));
|
||||
assert!(!should_create_public_listener(
|
||||
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_selection_prefers_port_mapping_until_available() {
|
||||
assert!(should_create_public_listener(1, true, false, false, true));
|
||||
assert!(!should_create_public_listener(1, true, true, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_selection_retry_respects_cap() {
|
||||
assert!(should_retry_public_listener_selection(
|
||||
false, 1, false, false
|
||||
));
|
||||
assert!(!should_retry_public_listener_selection(
|
||||
false,
|
||||
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
|
||||
false,
|
||||
false
|
||||
));
|
||||
assert!(!should_retry_public_listener_selection(
|
||||
true, 1, false, false
|
||||
));
|
||||
assert!(!should_retry_public_listener_selection(
|
||||
false, 1, true, true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ use std::{
|
||||
|
||||
use anyhow::Context;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use crate::{
|
||||
common::{PeerId, scoped_task::ScopedTask, stun::StunInfoCollectorTrait},
|
||||
common::{PeerId, upnp},
|
||||
connector::udp_hole_punch::common::{
|
||||
HOLE_PUNCH_PACKET_BODY_LEN, UdpSocketArray, try_connect_with_socket,
|
||||
},
|
||||
@@ -113,29 +114,7 @@ impl PunchConeHoleClient {
|
||||
|
||||
let global_ctx = self.peer_mgr.get_global_ctx();
|
||||
let udp_array = UdpSocketArray::new(1, global_ctx.net_ns.clone());
|
||||
let local_socket = {
|
||||
let _g = self.peer_mgr.get_global_ctx().net_ns.guard();
|
||||
Arc::new(UdpSocket::bind("0.0.0.0:0").await?)
|
||||
};
|
||||
|
||||
let local_addr = local_socket
|
||||
.local_addr()
|
||||
.with_context(|| "failed to get local port from udp array")?;
|
||||
let local_port = local_addr.port();
|
||||
|
||||
drop(local_socket);
|
||||
let local_mapped_addr = global_ctx
|
||||
.get_stun_info_collector()
|
||||
.get_udp_port_mapping(local_port)
|
||||
.await
|
||||
.with_context(|| "failed to get udp port mapping")?;
|
||||
|
||||
let local_socket = {
|
||||
let _g = self.peer_mgr.get_global_ctx().net_ns.guard();
|
||||
Arc::new(UdpSocket::bind(local_addr).await?)
|
||||
};
|
||||
|
||||
// client -> server: tell server the mapped port, server will return the mapped address of listening port.
|
||||
let rpc_stub = self
|
||||
.peer_mgr
|
||||
.get_peer_rpc_mgr()
|
||||
@@ -149,7 +128,10 @@ impl PunchConeHoleClient {
|
||||
let resp = rpc_stub
|
||||
.select_punch_listener(
|
||||
BaseController::default(),
|
||||
SelectPunchListenerRequest { force_new: false },
|
||||
SelectPunchListenerRequest {
|
||||
force_new: false,
|
||||
prefer_port_mapping: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -159,6 +141,24 @@ impl PunchConeHoleClient {
|
||||
"select_punch_listener response missing listener_mapped_addr"
|
||||
))?;
|
||||
|
||||
let local_socket = {
|
||||
let _g = self.peer_mgr.get_global_ctx().net_ns.guard();
|
||||
Arc::new(UdpSocket::bind("0.0.0.0:0").await?)
|
||||
};
|
||||
let local_addr = local_socket
|
||||
.local_addr()
|
||||
.with_context(|| "failed to get local addr from udp punch socket")?;
|
||||
let local_listener: url::Url = format!("udp://0.0.0.0:{}", local_addr.port())
|
||||
.parse()
|
||||
.unwrap();
|
||||
let (local_mapped_addr, _local_port_mapping_lease) = upnp::resolve_udp_public_addr(
|
||||
global_ctx.clone(),
|
||||
&local_listener,
|
||||
local_socket.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| "failed to resolve udp public addr for cone hole punch")?;
|
||||
|
||||
tracing::debug!(
|
||||
?local_mapped_addr,
|
||||
?remote_mapped_addr,
|
||||
@@ -179,7 +179,7 @@ impl PunchConeHoleClient {
|
||||
|
||||
send_from_local().await?;
|
||||
|
||||
let scoped_punch_task: ScopedTask<()> = tokio::spawn(async move {
|
||||
let punch_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
if let Err(e) = rpc_stub
|
||||
.send_punch_packet_cone(
|
||||
BaseController {
|
||||
@@ -199,8 +199,7 @@ impl PunchConeHoleClient {
|
||||
{
|
||||
tracing::error!(?e, "failed to call remote send punch packet");
|
||||
}
|
||||
})
|
||||
.into();
|
||||
}));
|
||||
|
||||
// server: will send some punching resps, total 10 packets.
|
||||
// client: use the socket to create UdpTunnel with UdpTunnelConnector
|
||||
@@ -209,7 +208,7 @@ impl PunchConeHoleClient {
|
||||
while finish_time.is_none() || finish_time.as_ref().unwrap().elapsed().as_millis() < 1000 {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
if finish_time.is_none() && (*scoped_punch_task).is_finished() {
|
||||
if finish_time.is_none() && punch_task.is_finished() {
|
||||
finish_time = Some(Instant::now());
|
||||
}
|
||||
|
||||
@@ -246,10 +245,15 @@ impl PunchConeHoleClient {
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
common::upnp::{
|
||||
reset_udp_port_mapping_attempts_for_test, udp_port_mapping_attempts_for_test,
|
||||
},
|
||||
connector::udp_hole_punch::{
|
||||
UdpHolePunchConnector, tests::create_mock_peer_manager_with_mock_stun,
|
||||
UdpHolePunchConnector, cone::PunchConeHoleClient,
|
||||
tests::create_mock_peer_manager_with_mock_stun,
|
||||
},
|
||||
peers::tests::{connect_peer_manager, wait_route_appear, wait_route_appear_with_cost},
|
||||
proto::common::NatType,
|
||||
@@ -280,4 +284,27 @@ pub mod tests {
|
||||
.unwrap();
|
||||
println!("{:?}", p_a.list_routes().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cone_hole_punch_does_not_create_upnp_mapping_before_listener_rpc_succeeds() {
|
||||
let p_a = create_mock_peer_manager_with_mock_stun(NatType::Restricted).await;
|
||||
let p_b = create_mock_peer_manager_with_mock_stun(NatType::PortRestricted).await;
|
||||
let p_c = create_mock_peer_manager_with_mock_stun(NatType::Restricted).await;
|
||||
connect_peer_manager(p_a.clone(), p_b.clone()).await;
|
||||
connect_peer_manager(p_b.clone(), p_c.clone()).await;
|
||||
wait_route_appear(p_a.clone(), p_c.clone()).await.unwrap();
|
||||
|
||||
let mut flags = p_a.get_global_ctx().get_flags();
|
||||
flags.disable_upnp = false;
|
||||
p_a.get_global_ctx().set_flags(flags);
|
||||
|
||||
reset_udp_port_mapping_attempts_for_test();
|
||||
|
||||
let ret = PunchConeHoleClient::new(p_a.clone(), Arc::new(timedmap::TimedMap::new()))
|
||||
.do_hole_punching(p_c.my_peer_id())
|
||||
.await;
|
||||
|
||||
assert!(ret.is_err());
|
||||
assert_eq!(udp_port_mapping_attempts_for_test(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ impl UdpHolePunchRpc for UdpHolePunchServer {
|
||||
) -> rpc_types::error::Result<SelectPunchListenerResponse> {
|
||||
let (_, addr) = self
|
||||
.common
|
||||
.select_listener(input.force_new)
|
||||
.select_listener(input.force_new, input.prefer_port_mapping)
|
||||
.await
|
||||
.ok_or(anyhow::anyhow!("no listener available"))?;
|
||||
|
||||
@@ -584,6 +584,11 @@ impl UdpHolePunchConnector {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn run_immediately_for_test(&self) {
|
||||
self.client.run_immediately().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -614,6 +619,9 @@ pub mod tests {
|
||||
udp_nat_type: NatType,
|
||||
) -> Arc<PeerManager> {
|
||||
let p_a = create_mock_peer_manager().await;
|
||||
let mut flags = p_a.get_global_ctx().get_flags();
|
||||
flags.disable_upnp = true;
|
||||
p_a.get_global_ctx().set_flags(flags);
|
||||
replace_stun_info_collector(p_a.clone(), udp_nat_type);
|
||||
p_a
|
||||
}
|
||||
|
||||
@@ -9,21 +9,20 @@ use std::{
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use guarden::defer;
|
||||
use rand::{Rng, seq::SliceRandom};
|
||||
use tokio::{net::UdpSocket, sync::RwLock};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use tracing::Level;
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
PeerId, global_ctx::ArcGlobalCtx, scoped_task::ScopedTask, stun::StunInfoCollectorTrait,
|
||||
},
|
||||
common::{PeerId, global_ctx::ArcGlobalCtx, stun::StunInfoCollectorTrait},
|
||||
connector::udp_hole_punch::{
|
||||
common::{
|
||||
HOLE_PUNCH_PACKET_BODY_LEN, send_symmetric_hole_punch_packet, try_connect_with_socket,
|
||||
},
|
||||
handle_rpc_result,
|
||||
},
|
||||
defer,
|
||||
peers::peer_manager::PeerManager,
|
||||
proto::{
|
||||
peer_rpc::{
|
||||
@@ -360,7 +359,7 @@ impl PunchSymToConeHoleClient {
|
||||
packet: &[u8],
|
||||
tid: u32,
|
||||
remote_mapped_addr: crate::proto::common::SocketAddr,
|
||||
scoped_punch_task: &ScopedTask<T>,
|
||||
punch_task: &AbortOnDropHandle<T>,
|
||||
) -> Result<Option<Box<dyn Tunnel>>, anyhow::Error> {
|
||||
// no matter what the result is, we should check if we received any hole punching packet
|
||||
let mut ret_tunnel: Option<Box<dyn Tunnel>> = None;
|
||||
@@ -372,7 +371,7 @@ impl PunchSymToConeHoleClient {
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
if finish_time.is_none() && (*scoped_punch_task).is_finished() {
|
||||
if finish_time.is_none() && punch_task.is_finished() {
|
||||
finish_time = Some(Instant::now());
|
||||
}
|
||||
|
||||
@@ -434,7 +433,10 @@ impl PunchSymToConeHoleClient {
|
||||
let resp = rpc_stub
|
||||
.select_punch_listener(
|
||||
BaseController::default(),
|
||||
SelectPunchListenerRequest { force_new: false },
|
||||
SelectPunchListenerRequest {
|
||||
force_new: false,
|
||||
prefer_port_mapping: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -479,27 +481,27 @@ impl PunchSymToConeHoleClient {
|
||||
|
||||
if self.punch_predicablely.load(Ordering::Relaxed) && base_port_for_easy_sym.is_some() {
|
||||
let rpc_stub = self.get_rpc_stub(dst_peer_id).await;
|
||||
let scoped_punch_task: ScopedTask<()> =
|
||||
tokio::spawn(Self::remote_send_hole_punch_packet_predicable(
|
||||
let punch_task = AbortOnDropHandle::new(tokio::spawn(
|
||||
Self::remote_send_hole_punch_packet_predicable(
|
||||
rpc_stub,
|
||||
base_port_for_easy_sym,
|
||||
my_nat_info,
|
||||
remote_mapped_addr,
|
||||
public_ips.clone(),
|
||||
tid,
|
||||
))
|
||||
.into();
|
||||
),
|
||||
));
|
||||
let ret_tunnel = Self::check_hole_punch_result(
|
||||
global_ctx.clone(),
|
||||
&udp_array,
|
||||
&packet,
|
||||
tid,
|
||||
remote_mapped_addr,
|
||||
&scoped_punch_task,
|
||||
&punch_task,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let task_ret = scoped_punch_task.await;
|
||||
let task_ret = punch_task.await;
|
||||
tracing::debug!(?ret_tunnel, ?task_ret, "predictable punch task got result");
|
||||
if let Some(tunnel) = ret_tunnel {
|
||||
return Ok(Some(tunnel));
|
||||
@@ -507,27 +509,26 @@ impl PunchSymToConeHoleClient {
|
||||
}
|
||||
|
||||
let rpc_stub = self.get_rpc_stub(dst_peer_id).await;
|
||||
let scoped_punch_task: ScopedTask<Option<u32>> =
|
||||
tokio::spawn(Self::remote_send_hole_punch_packet_random(
|
||||
let punch_task =
|
||||
AbortOnDropHandle::new(tokio::spawn(Self::remote_send_hole_punch_packet_random(
|
||||
rpc_stub,
|
||||
remote_mapped_addr,
|
||||
public_ips.clone(),
|
||||
tid,
|
||||
round,
|
||||
port_index,
|
||||
))
|
||||
.into();
|
||||
)));
|
||||
let ret_tunnel = Self::check_hole_punch_result(
|
||||
global_ctx,
|
||||
&udp_array,
|
||||
&packet,
|
||||
tid,
|
||||
remote_mapped_addr,
|
||||
&scoped_punch_task,
|
||||
&punch_task,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let punch_task_result = scoped_punch_task.await;
|
||||
let punch_task_result = punch_task.await;
|
||||
tracing::debug!(?punch_task_result, ?ret_tunnel, "punch task got result");
|
||||
|
||||
if let Ok(Some(next_port_idx)) = punch_task_result {
|
||||
@@ -641,7 +642,7 @@ pub mod tests {
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(hole_punch)]
|
||||
async fn hole_punching_symmetric_only_predict(#[values("true", "false")] is_inc: bool) {
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
RUN_TESTING.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
@@ -691,12 +692,12 @@ pub mod tests {
|
||||
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let mut tasks: Vec<ScopedTask<()>> = vec![];
|
||||
let mut tasks: Vec<AbortOnDropHandle<()>> = vec![];
|
||||
|
||||
// all these sockets should receive hole punching packet
|
||||
for udp in udps.iter().map(Arc::clone) {
|
||||
let counter = counter.clone();
|
||||
tasks.push(ScopedTask::from(tokio::spawn(async move {
|
||||
tasks.push(AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let mut buf = [0u8; 1024];
|
||||
let (len, addr) = udp.recv_from(&mut buf).await.unwrap();
|
||||
println!(
|
||||
|
||||
+212
-84
@@ -1,24 +1,17 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
path::PathBuf,
|
||||
process::ExitCode,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ShellType,
|
||||
common::{
|
||||
config::{
|
||||
ConfigFileControl, ConfigLoader, ConsoleLoggerConfig, EncryptionAlgorithm,
|
||||
FileLoggerConfig, LoggingConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig,
|
||||
TomlConfigLoader, VpnPortalConfig, load_config_from_file, process_secure_mode_cfg,
|
||||
TomlConfigLoader, VpnPortalConfig, load_config_from_file, parse_mapped_listener_urls,
|
||||
process_secure_mode_cfg,
|
||||
},
|
||||
constants::EASYTIER_VERSION,
|
||||
log,
|
||||
},
|
||||
defer,
|
||||
instance_manager::NetworkInstanceManager,
|
||||
launcher::add_proxy_network_to_config,
|
||||
proto::common::{CompressionAlgoPb, SecureModeConfig},
|
||||
@@ -29,7 +22,14 @@ use crate::{
|
||||
use anyhow::Context;
|
||||
use cidr::IpCidr;
|
||||
use clap::{CommandFactory, Parser};
|
||||
use guarden::defer;
|
||||
use rust_i18n::t;
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
path::PathBuf,
|
||||
process::ExitCode,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
};
|
||||
use strum::VariantArray;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
@@ -171,6 +171,31 @@ struct NetworkOptions {
|
||||
)]
|
||||
ipv6: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "ET_IPV6_PUBLIC_ADDR_PROVIDER",
|
||||
help = t!("core_clap.ipv6_public_addr_provider").to_string(),
|
||||
num_args = 0..=1,
|
||||
default_missing_value = "true"
|
||||
)]
|
||||
ipv6_public_addr_provider: Option<bool>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "ET_IPV6_PUBLIC_ADDR_AUTO",
|
||||
help = t!("core_clap.ipv6_public_addr_auto").to_string(),
|
||||
num_args = 0..=1,
|
||||
default_missing_value = "true"
|
||||
)]
|
||||
ipv6_public_addr_auto: Option<bool>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "ET_IPV6_PUBLIC_ADDR_PREFIX",
|
||||
help = t!("core_clap.ipv6_public_addr_prefix").to_string()
|
||||
)]
|
||||
ipv6_public_addr_prefix: Option<String>,
|
||||
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
@@ -450,6 +475,15 @@ struct NetworkOptions {
|
||||
)]
|
||||
disable_sym_hole_punching: Option<bool>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "ET_DISABLE_UPNP",
|
||||
help = t!("core_clap.disable_upnp").to_string(),
|
||||
num_args = 0..=1,
|
||||
default_missing_value = "true"
|
||||
)]
|
||||
disable_upnp: Option<bool>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "ET_RELAY_ALL_PEER_RPC",
|
||||
@@ -735,55 +769,69 @@ struct RpcPortalOptions {
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
fn gen_listeners(addr: SocketAddr) -> impl Iterator<Item = String> {
|
||||
let dynamic = addr.port() == 0;
|
||||
IpScheme::VARIANTS.iter().map(move |proto| {
|
||||
let mut addr = addr;
|
||||
if !dynamic {
|
||||
addr.set_port(addr.port() + proto.port_offset());
|
||||
}
|
||||
format!("{}://{}", proto, addr)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_listeners(no_listener: bool, listeners: Vec<String>) -> anyhow::Result<Vec<String>> {
|
||||
if no_listener || listeners.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
if listeners.len() == 1
|
||||
&& let Ok(port) = listeners[0].parse::<u16>()
|
||||
{
|
||||
let listeners = IpScheme::VARIANTS
|
||||
.iter()
|
||||
.map(|proto| {
|
||||
format!(
|
||||
"{}://0.0.0.0:{}",
|
||||
proto,
|
||||
if port == 0 {
|
||||
0
|
||||
} else {
|
||||
port + proto.port_offset()
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
return Ok(listeners);
|
||||
let mut parsed = vec![];
|
||||
|
||||
for l in listeners.into_iter() {
|
||||
if let Ok(port) = l.parse::<u16>() {
|
||||
parsed.extend(Self::gen_listeners(SocketAddr::new(
|
||||
"0.0.0.0".parse()?,
|
||||
port,
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(ip) = l.trim_matches(|c| c == '[' || c == ']').parse::<IpAddr>() {
|
||||
parsed.extend(Self::gen_listeners(SocketAddr::new(ip, 11010)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(addr) = l.parse::<SocketAddr>() {
|
||||
parsed.extend(Self::gen_listeners(addr));
|
||||
continue;
|
||||
}
|
||||
|
||||
let (scheme, rest) = l.split_once(':').unwrap_or((&l, ""));
|
||||
let Ok(scheme) = scheme.parse::<IpScheme>() else {
|
||||
anyhow::bail!("invalid listener: {}", l);
|
||||
};
|
||||
|
||||
if rest.is_empty() {
|
||||
parsed.push(format!(
|
||||
"{}://0.0.0.0:{}",
|
||||
scheme,
|
||||
11010 + scheme.port_offset()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(port) = rest.parse::<u16>() {
|
||||
parsed.push(format!("{}://0.0.0.0:{}", scheme, port));
|
||||
continue;
|
||||
}
|
||||
|
||||
if !l.parse::<url::Url>()?.has_authority() {
|
||||
anyhow::bail!("invalid listener: {}", l);
|
||||
}
|
||||
parsed.push(l);
|
||||
}
|
||||
|
||||
listeners
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let l = l
|
||||
.parse::<url::Url>()
|
||||
.or_else(|_| url::Url::parse(&format!("{}:", l)))?;
|
||||
|
||||
if l.has_authority() {
|
||||
return Ok(l.to_string());
|
||||
}
|
||||
|
||||
let scheme: IpScheme = l.scheme().parse()?;
|
||||
let port = {
|
||||
let port = l.path();
|
||||
if port.is_empty() {
|
||||
11010 + scheme.port_offset()
|
||||
} else {
|
||||
port.parse::<u16>()
|
||||
.with_context(|| format!("invalid port: {}", port))?
|
||||
}
|
||||
};
|
||||
Ok(format!("{}://0.0.0.0:{}", scheme, port))
|
||||
})
|
||||
.collect()
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,7 +839,7 @@ impl NetworkOptions {
|
||||
fn can_merge(
|
||||
&self,
|
||||
cfg: &TomlConfigLoader,
|
||||
source: ConfigSource,
|
||||
source: ConfigFileSource,
|
||||
explicit_config_file_count: usize,
|
||||
config_dir_file_count: usize,
|
||||
) -> bool {
|
||||
@@ -799,7 +847,7 @@ impl NetworkOptions {
|
||||
return false;
|
||||
}
|
||||
|
||||
if source == ConfigSource::CliConfigFile
|
||||
if source == ConfigFileSource::CliConfigFile
|
||||
&& explicit_config_file_count == 1
|
||||
&& config_dir_file_count == 0
|
||||
{
|
||||
@@ -810,7 +858,7 @@ impl NetworkOptions {
|
||||
return false;
|
||||
};
|
||||
|
||||
if source == ConfigSource::ConfigDir {
|
||||
if source == ConfigFileSource::ConfigDir {
|
||||
return cfg.get_network_identity().network_name == *network_name;
|
||||
}
|
||||
|
||||
@@ -852,6 +900,20 @@ impl NetworkOptions {
|
||||
})?))
|
||||
}
|
||||
|
||||
if let Some(enabled) = self.ipv6_public_addr_provider {
|
||||
cfg.set_ipv6_public_addr_provider(enabled);
|
||||
}
|
||||
|
||||
if let Some(enabled) = self.ipv6_public_addr_auto {
|
||||
cfg.set_ipv6_public_addr_auto(enabled);
|
||||
}
|
||||
|
||||
if let Some(prefix) = &self.ipv6_public_addr_prefix {
|
||||
cfg.set_ipv6_public_addr_prefix(Some(prefix.parse().with_context(|| {
|
||||
format!("failed to parse ipv6 public address prefix: {}", prefix)
|
||||
})?));
|
||||
}
|
||||
|
||||
if !self.peers.is_empty() {
|
||||
let mut peers = cfg.get_peers();
|
||||
peers.reserve(peers.len() + self.peers.len());
|
||||
@@ -884,32 +946,7 @@ impl NetworkOptions {
|
||||
}
|
||||
|
||||
if !self.mapped_listeners.is_empty() {
|
||||
let mut errs = Vec::new();
|
||||
cfg.set_mapped_listeners(Some(
|
||||
self.mapped_listeners
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.parse()
|
||||
.with_context(|| format!("mapped listener is not a valid url: {}", s))
|
||||
.unwrap()
|
||||
})
|
||||
.map(|s: url::Url| {
|
||||
if s.port().is_none() {
|
||||
errs.push(anyhow::anyhow!("mapped listener port is missing: {}", s));
|
||||
}
|
||||
s
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
));
|
||||
if !errs.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{}",
|
||||
errs.iter()
|
||||
.map(|x| format!("{}", x))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
));
|
||||
}
|
||||
cfg.set_mapped_listeners(Some(parse_mapped_listener_urls(&self.mapped_listeners)?));
|
||||
}
|
||||
|
||||
for n in self.proxy_networks.iter() {
|
||||
@@ -1101,7 +1138,10 @@ impl NetworkOptions {
|
||||
f.enable_relay_foreign_network_quic = self
|
||||
.enable_relay_foreign_network_quic
|
||||
.unwrap_or(f.enable_relay_foreign_network_quic);
|
||||
f.disable_sym_hole_punching = self.disable_sym_hole_punching.unwrap_or(false);
|
||||
f.disable_sym_hole_punching = self
|
||||
.disable_sym_hole_punching
|
||||
.unwrap_or(f.disable_sym_hole_punching);
|
||||
f.disable_upnp = self.disable_upnp.unwrap_or(f.disable_upnp);
|
||||
// Configure tld_dns_zone: use provided value if set
|
||||
if let Some(tld_dns_zone) = &self.tld_dns_zone {
|
||||
f.tld_dns_zone = tld_dns_zone.clone();
|
||||
@@ -1136,7 +1176,7 @@ impl NetworkOptions {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ConfigSource {
|
||||
enum ConfigFileSource {
|
||||
CliConfigFile,
|
||||
ConfigDir,
|
||||
}
|
||||
@@ -1328,7 +1368,7 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
||||
let mut config_files = if let Some(v) = cli.config_file {
|
||||
v.iter()
|
||||
.cloned()
|
||||
.map(|path| (path, ConfigSource::CliConfigFile))
|
||||
.map(|path| (path, ConfigFileSource::CliConfigFile))
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
@@ -1351,7 +1391,7 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
||||
continue;
|
||||
}
|
||||
config_dir_file_count += 1;
|
||||
config_files.push((path, ConfigSource::ConfigDir));
|
||||
config_files.push((path, ConfigFileSource::ConfigDir));
|
||||
}
|
||||
}
|
||||
let config_file_count = config_files.len();
|
||||
@@ -1582,3 +1622,91 @@ async fn validate_config(cli: &Cli) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_listeners() {
|
||||
type IpSchemeMap = fn(&IpScheme) -> String;
|
||||
|
||||
let cases: [(&str, IpSchemeMap); _] = [
|
||||
("0", |s| format!("{}://0.0.0.0:0", s)),
|
||||
("11010", |s| {
|
||||
format!("{}://0.0.0.0:{}", s, 11010 + s.port_offset())
|
||||
}),
|
||||
("1.1.1.1", |s| {
|
||||
format!("{}://1.1.1.1:{}", s, 11010 + s.port_offset())
|
||||
}),
|
||||
("1.1.1.1:50000", |s| {
|
||||
format!("{}://1.1.1.1:{}", s, 50000 + s.port_offset())
|
||||
}),
|
||||
("[::1]", |s| {
|
||||
format!("{}://[::1]:{}", s, 11010 + s.port_offset())
|
||||
}),
|
||||
("[::1]:50000", |s| {
|
||||
format!("{}://[::1]:{}", s, 50000 + s.port_offset())
|
||||
}),
|
||||
];
|
||||
|
||||
for (input, output) in cases {
|
||||
assert_eq!(
|
||||
Cli::parse_listeners(false, vec![input.to_string()]).unwrap(),
|
||||
IpScheme::VARIANTS.iter().map(output).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
let input = cases.iter().map(|(i, _)| i.to_string()).collect::<Vec<_>>();
|
||||
let output = cases
|
||||
.iter()
|
||||
.flat_map(|(_, o)| IpScheme::VARIANTS.iter().map(o))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(Cli::parse_listeners(false, input).unwrap(), output);
|
||||
|
||||
let cases: [(IpSchemeMap, IpSchemeMap); _] = [
|
||||
(
|
||||
|s| format!("{}", s),
|
||||
|s| format!("{}://0.0.0.0:{}", s, 11010 + s.port_offset()),
|
||||
),
|
||||
(
|
||||
|s| format!("{}:50000", s),
|
||||
|s| format!("{}://0.0.0.0:50000", s),
|
||||
),
|
||||
(
|
||||
|s| format!("{}://1.1.1.1:50000", s),
|
||||
|s| format!("{}://1.1.1.1:50000", s),
|
||||
),
|
||||
];
|
||||
|
||||
for (input, output) in cases {
|
||||
assert_eq!(
|
||||
Cli::parse_listeners(
|
||||
false,
|
||||
IpScheme::VARIANTS.iter().map(input).collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap(),
|
||||
IpScheme::VARIANTS.iter().map(output).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
let input = cases
|
||||
.iter()
|
||||
.flat_map(|(i, _)| IpScheme::VARIANTS.iter().map(i))
|
||||
.collect::<Vec<_>>();
|
||||
let output = cases
|
||||
.iter()
|
||||
.flat_map(|(_, o)| IpScheme::VARIANTS.iter().map(o))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(Cli::parse_listeners(false, input).unwrap(), output);
|
||||
|
||||
let cases = ["tcp://[::1", "xxx", "tcp:/abc", "tcp:abc"];
|
||||
for input in cases {
|
||||
assert!(
|
||||
Cli::parse_listeners(false, vec![input.to_string()]).is_err(),
|
||||
"input: {}",
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use anyhow::Context;
|
||||
use base64::Engine as _;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use cidr::Ipv4Inet;
|
||||
use clap::{Args, CommandFactory, Parser, Subcommand};
|
||||
use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, builder::BoolishValueParser};
|
||||
use dashmap::DashMap;
|
||||
use easytier::ShellType;
|
||||
use humansize::format_size;
|
||||
@@ -51,13 +51,14 @@ use easytier::{
|
||||
ListCredentialsRequest, ListCredentialsResponse, ListForeignNetworkRequest,
|
||||
ListGlobalForeignNetworkRequest, ListMappedListenerRequest, ListPeerRequest,
|
||||
ListPeerResponse, ListPortForwardRequest, ListPortForwardResponse,
|
||||
ListRouteRequest, ListRouteResponse, MappedListener, MappedListenerManageRpc,
|
||||
ListPublicIpv6InfoRequest, ListPublicIpv6InfoResponse, ListRouteRequest,
|
||||
ListRouteResponse, MappedListener, MappedListenerManageRpc,
|
||||
MappedListenerManageRpcClientFactory, MetricSnapshot, NodeInfo, PeerManageRpc,
|
||||
PeerManageRpcClientFactory, PortForwardManageRpc,
|
||||
PortForwardManageRpcClientFactory, RevokeCredentialRequest, ShowNodeInfoRequest,
|
||||
StatsRpc, StatsRpcClientFactory, TcpProxyEntryState, TcpProxyEntryTransportType,
|
||||
TcpProxyRpc, TcpProxyRpcClientFactory, TrustedKeySourcePb, VpnPortalInfo,
|
||||
VpnPortalRpc, VpnPortalRpcClientFactory,
|
||||
PortForwardManageRpcClientFactory, RevokeCredentialRequest, Route as ApiRoute,
|
||||
ShowNodeInfoRequest, StatsRpc, StatsRpcClientFactory, TcpProxyEntryState,
|
||||
TcpProxyEntryTransportType, TcpProxyRpc, TcpProxyRpcClientFactory,
|
||||
TrustedKeySourcePb, VpnPortalInfo, VpnPortalRpc, VpnPortalRpcClientFactory,
|
||||
instance_identifier::{InstanceSelector, Selector},
|
||||
list_global_foreign_network_response, list_peer_route_pair,
|
||||
},
|
||||
@@ -193,6 +194,7 @@ struct PeerArgs {
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum PeerSubCommand {
|
||||
List,
|
||||
Ipv6,
|
||||
ListForeign {
|
||||
#[arg(
|
||||
long,
|
||||
@@ -402,6 +404,14 @@ enum CredentialSubCommand {
|
||||
help = "allowed proxy CIDRs (comma-separated)"
|
||||
)]
|
||||
allowed_proxy_cidrs: Option<Vec<String>>,
|
||||
#[arg(
|
||||
long,
|
||||
action = ArgAction::Set,
|
||||
default_value = "true",
|
||||
value_parser = BoolishValueParser::new(),
|
||||
help = "whether this credential may be reused by multiple peers concurrently"
|
||||
)]
|
||||
reusable: bool,
|
||||
},
|
||||
/// Revoke a credential by its ID
|
||||
Revoke {
|
||||
@@ -528,6 +538,12 @@ struct RouteListData {
|
||||
peer_routes: Vec<PeerRoutePair>,
|
||||
}
|
||||
|
||||
struct PeerIpv6DataRaw {
|
||||
node_info: NodeInfo,
|
||||
routes: Vec<ApiRoute>,
|
||||
provider_info: ListPublicIpv6InfoResponse,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct PeerCenterRowData {
|
||||
node_id: String,
|
||||
@@ -955,6 +971,27 @@ impl<'a> CommandHandler<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_local_public_ipv6_info(&self) -> Result<ListPublicIpv6InfoResponse, Error> {
|
||||
Ok(self
|
||||
.get_peer_manager_client()
|
||||
.await?
|
||||
.list_public_ipv6_info(
|
||||
BaseController::default(),
|
||||
ListPublicIpv6InfoRequest {
|
||||
instance: Some(self.instance_selector.clone()),
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn fetch_peer_ipv6_data(&self) -> Result<PeerIpv6DataRaw, Error> {
|
||||
Ok(PeerIpv6DataRaw {
|
||||
node_info: self.fetch_node_info().await?,
|
||||
routes: self.list_routes().await?.routes,
|
||||
provider_info: self.fetch_local_public_ipv6_info().await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_connector_list(&self) -> Result<Vec<Connector>, Error> {
|
||||
Ok(self
|
||||
.get_connector_manager_client()
|
||||
@@ -1367,6 +1404,154 @@ impl<'a> CommandHandler<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_peer_ipv6(&self) -> Result<(), Error> {
|
||||
#[derive(tabled::Tabled, serde::Serialize)]
|
||||
struct PeerIpv6NodeRow {
|
||||
peer_id: u32,
|
||||
hostname: String,
|
||||
inst_id: String,
|
||||
ipv4: String,
|
||||
public_ipv6_addr: String,
|
||||
provider_prefix: String,
|
||||
}
|
||||
|
||||
#[derive(tabled::Tabled, serde::Serialize)]
|
||||
struct ProviderLeaseRow {
|
||||
peer_id: u32,
|
||||
inst_id: String,
|
||||
leased_addr: String,
|
||||
valid_until: String,
|
||||
reused: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ProviderLeaseSection {
|
||||
provider_prefix: String,
|
||||
leases: Vec<ProviderLeaseRow>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct PeerIpv6View {
|
||||
nodes: Vec<PeerIpv6NodeRow>,
|
||||
local_provider: Option<ProviderLeaseSection>,
|
||||
}
|
||||
|
||||
fn fmt_ipv6_inet(value: Option<easytier::proto::common::Ipv6Inet>) -> String {
|
||||
value
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn fmt_valid_until(unix_seconds: i64) -> String {
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(unix_seconds, 0)
|
||||
.map(|ts| {
|
||||
ts.with_timezone(&chrono::Local)
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| unix_seconds.to_string())
|
||||
}
|
||||
|
||||
let build_view = |data: &PeerIpv6DataRaw| {
|
||||
let mut nodes = Vec::with_capacity(data.routes.len() + 1);
|
||||
nodes.push(PeerIpv6NodeRow {
|
||||
peer_id: data.node_info.peer_id,
|
||||
hostname: data.node_info.hostname.clone(),
|
||||
inst_id: data.node_info.inst_id.clone(),
|
||||
ipv4: data.node_info.ipv4_addr.clone(),
|
||||
public_ipv6_addr: fmt_ipv6_inet(data.node_info.public_ipv6_addr),
|
||||
provider_prefix: fmt_ipv6_inet(data.node_info.ipv6_public_addr_prefix),
|
||||
});
|
||||
nodes.extend(data.routes.iter().map(|route| {
|
||||
PeerIpv6NodeRow {
|
||||
peer_id: route.peer_id,
|
||||
hostname: route.hostname.clone(),
|
||||
inst_id: route.inst_id.clone(),
|
||||
ipv4: route
|
||||
.ipv4_addr
|
||||
.map(|ipv4| ipv4.to_string())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
public_ipv6_addr: fmt_ipv6_inet(route.public_ipv6_addr),
|
||||
provider_prefix: fmt_ipv6_inet(route.ipv6_public_addr_prefix),
|
||||
}
|
||||
}));
|
||||
nodes.sort_by_key(|row| {
|
||||
(
|
||||
row.peer_id != data.node_info.peer_id,
|
||||
row.peer_id,
|
||||
row.inst_id.clone(),
|
||||
)
|
||||
});
|
||||
|
||||
let local_provider = data.provider_info.provider_prefix.map(|provider_prefix| {
|
||||
let mut leases = data
|
||||
.provider_info
|
||||
.provider_leases
|
||||
.iter()
|
||||
.map(|lease| ProviderLeaseRow {
|
||||
peer_id: lease.peer_id,
|
||||
inst_id: lease.inst_id.clone(),
|
||||
leased_addr: fmt_ipv6_inet(lease.leased_addr),
|
||||
valid_until: fmt_valid_until(lease.valid_until_unix_seconds),
|
||||
reused: lease.reused,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
leases.sort_by_key(|lease| {
|
||||
(
|
||||
lease.peer_id,
|
||||
lease.inst_id.clone(),
|
||||
lease.leased_addr.clone(),
|
||||
)
|
||||
});
|
||||
ProviderLeaseSection {
|
||||
provider_prefix: provider_prefix.to_string(),
|
||||
leases,
|
||||
}
|
||||
});
|
||||
|
||||
PeerIpv6View {
|
||||
nodes,
|
||||
local_provider,
|
||||
}
|
||||
};
|
||||
|
||||
let results = self
|
||||
.collect_instance_results(|handler| Box::pin(handler.fetch_peer_ipv6_data()))
|
||||
.await?;
|
||||
|
||||
if self.verbose || *self.output_format == OutputFormat::Json {
|
||||
return self.print_json_results(
|
||||
results
|
||||
.into_iter()
|
||||
.map(|result| result.map(|data| build_view(&data)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
self.print_results(&results, |data| {
|
||||
let view = build_view(data);
|
||||
print_output(&view.nodes, self.output_format, &[], &[], self.no_trunc)?;
|
||||
|
||||
if let Some(local_provider) = view.local_provider {
|
||||
println!();
|
||||
println!("Local provider prefix: {}", local_provider.provider_prefix);
|
||||
if local_provider.leases.is_empty() {
|
||||
println!("No active provider leases");
|
||||
} else {
|
||||
print_output(
|
||||
&local_provider.leases,
|
||||
self.output_format,
|
||||
&[],
|
||||
&[],
|
||||
self.no_trunc,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_route_dump(&self) -> Result<(), Error> {
|
||||
let results = self
|
||||
.collect_instance_results(|handler| Box::pin(handler.fetch_route_dump()))
|
||||
@@ -2008,6 +2193,7 @@ impl<'a> CommandHandler<'a> {
|
||||
groups: Vec<String>,
|
||||
allow_relay: bool,
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
reusable: bool,
|
||||
) -> Result<(), Error> {
|
||||
let results = self
|
||||
.collect_instance_results(|handler| {
|
||||
@@ -2027,6 +2213,7 @@ impl<'a> CommandHandler<'a> {
|
||||
allowed_proxy_cidrs,
|
||||
ttl_seconds: ttl,
|
||||
instance: Some(handler.instance_selector.clone()),
|
||||
reusable: Some(reusable),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2104,7 +2291,14 @@ impl<'a> CommandHandler<'a> {
|
||||
} else {
|
||||
use tabled::{builder::Builder, settings::Style};
|
||||
let mut builder = Builder::default();
|
||||
builder.push_record(["ID", "Groups", "Relay", "Expiry", "Allowed CIDRs"]);
|
||||
builder.push_record([
|
||||
"ID",
|
||||
"Groups",
|
||||
"Relay",
|
||||
"Reusable",
|
||||
"Expiry",
|
||||
"Allowed CIDRs",
|
||||
]);
|
||||
for cred in &response.credentials {
|
||||
let expiry = {
|
||||
let secs = cred.expiry_unix;
|
||||
@@ -2123,6 +2317,11 @@ impl<'a> CommandHandler<'a> {
|
||||
&cred.credential_id[..],
|
||||
&cred.groups.join(","),
|
||||
if cred.allow_relay { "yes" } else { "no" },
|
||||
if cred.reusable.unwrap_or(true) {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
},
|
||||
&expiry,
|
||||
&cred.allowed_proxy_cidrs.join(","),
|
||||
]);
|
||||
@@ -2630,6 +2829,9 @@ async fn main() -> Result<(), Error> {
|
||||
Some(PeerSubCommand::List) => {
|
||||
handler.handle_peer_list().await?;
|
||||
}
|
||||
Some(PeerSubCommand::Ipv6) => {
|
||||
handler.handle_peer_ipv6().await?;
|
||||
}
|
||||
Some(PeerSubCommand::ListForeign { trusted_keys }) => {
|
||||
handler.handle_foreign_network_list(*trusted_keys).await?;
|
||||
}
|
||||
@@ -2921,6 +3123,7 @@ async fn main() -> Result<(), Error> {
|
||||
groups,
|
||||
allow_relay,
|
||||
allowed_proxy_cidrs,
|
||||
reusable,
|
||||
} => {
|
||||
handler
|
||||
.handle_credential_generate(
|
||||
@@ -2929,6 +3132,7 @@ async fn main() -> Result<(), Error> {
|
||||
groups.clone().unwrap_or_default(),
|
||||
*allow_relay,
|
||||
allowed_proxy_cidrs.clone().unwrap_or_default(),
|
||||
*reusable,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::{
|
||||
use anyhow::Context;
|
||||
use bytes::Bytes;
|
||||
use dashmap::DashMap;
|
||||
use guarden::defer;
|
||||
use kcp_sys::{
|
||||
endpoint::{ConnId, KcpEndpoint, KcpPacketReceiver},
|
||||
ffi_safe::KcpConfig,
|
||||
@@ -359,7 +360,7 @@ impl KcpProxyDst {
|
||||
transport_type: TcpProxyEntryTransportType::Kcp.into(),
|
||||
},
|
||||
);
|
||||
crate::defer! {
|
||||
defer! {
|
||||
proxy_entries.remove(&conn_id);
|
||||
if proxy_entries.capacity() - proxy_entries.len() > 16 {
|
||||
proxy_entries.shrink_to_fit();
|
||||
|
||||
@@ -24,6 +24,7 @@ use bytes::{BufMut, Bytes, BytesMut};
|
||||
use dashmap::DashMap;
|
||||
use derivative::Derivative;
|
||||
use derive_more::{Constructor, Deref, DerefMut, From, Into};
|
||||
use guarden::defer;
|
||||
use prost::Message;
|
||||
use quinn::udp::{EcnCodepoint, RecvMeta, Transmit};
|
||||
use quinn::{
|
||||
@@ -662,7 +663,7 @@ impl QuicStreamReceiver {
|
||||
transport_type: TcpProxyEntryTransportType::Quic.into(),
|
||||
},
|
||||
);
|
||||
crate::defer! {
|
||||
defer! {
|
||||
proxy_entries.remove(&handle);
|
||||
if proxy_entries.capacity() - proxy_entries.len() > 16 {
|
||||
proxy_entries.shrink_to_fit();
|
||||
|
||||
@@ -12,14 +12,12 @@ use crossbeam::atomic::AtomicCell;
|
||||
#[cfg(feature = "kcp")]
|
||||
use kcp_sys::{endpoint::KcpEndpoint, stream::KcpStream};
|
||||
use tokio_util::sync::{CancellationToken, DropGuard};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
#[cfg(feature = "kcp")]
|
||||
use crate::gateway::kcp_proxy::NatDstKcpConnector;
|
||||
use crate::{
|
||||
common::{
|
||||
config::PortForwardConfig, global_ctx::GlobalCtxEvent, join_joinset_background,
|
||||
scoped_task::ScopedTask,
|
||||
},
|
||||
common::{config::PortForwardConfig, global_ctx::GlobalCtxEvent, join_joinset_background},
|
||||
gateway::{
|
||||
fast_socks5::{
|
||||
server::{
|
||||
@@ -473,7 +471,7 @@ pub struct Socks5Server {
|
||||
entries: Socks5EntrySet,
|
||||
|
||||
udp_client_map: Arc<DashMap<UdpClientKey, Arc<UdpClientInfo>>>,
|
||||
udp_forward_task: Arc<DashMap<UdpClientKey, ScopedTask<()>>>,
|
||||
udp_forward_task: Arc<DashMap<UdpClientKey, AbortOnDropHandle<()>>>,
|
||||
|
||||
#[cfg(feature = "kcp")]
|
||||
kcp_endpoint: Mutex<Option<Weak<KcpEndpoint>>>,
|
||||
@@ -997,7 +995,7 @@ impl Socks5Server {
|
||||
let client_addr = addr;
|
||||
udp_forward_task.insert(
|
||||
udp_client_key.clone(),
|
||||
ScopedTask::from(tokio::spawn(async move {
|
||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
loop {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
match socks_udp.recv_from(&mut buf).await {
|
||||
|
||||
@@ -22,8 +22,7 @@ use smoltcp::{
|
||||
pub use socket::{TcpListener, TcpStream, UdpSocket};
|
||||
pub use socket_allocator::BufferSize;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
/// The async devices.
|
||||
pub mod channel_device;
|
||||
@@ -79,7 +78,7 @@ pub struct Net {
|
||||
ip_addr: IpCidr,
|
||||
from_port: AtomicU16,
|
||||
stopper: Arc<Notify>,
|
||||
fut: ScopedTask<io::Result<()>>,
|
||||
fut: AbortOnDropHandle<io::Result<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Net {
|
||||
@@ -131,7 +130,7 @@ impl Net {
|
||||
ip_addr: config.ip_addr,
|
||||
from_port: AtomicU16::new(10001),
|
||||
stopper,
|
||||
fut: ScopedTask::from(tokio::spawn(fut)),
|
||||
fut: AbortOnDropHandle::new(tokio::spawn(fut)),
|
||||
}
|
||||
}
|
||||
pub fn get_address(&self) -> IpAddr {
|
||||
|
||||
@@ -21,13 +21,14 @@ use tokio::{
|
||||
task::{JoinHandle, JoinSet},
|
||||
time::timeout,
|
||||
};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use tracing::Level;
|
||||
|
||||
use super::{CidrSet, ip_reassembler::IpReassembler};
|
||||
use crate::tunnel::common::bind;
|
||||
use crate::{
|
||||
common::{PeerId, error::Error, global_ctx::ArcGlobalCtx, scoped_task::ScopedTask},
|
||||
common::{PeerId, error::Error, global_ctx::ArcGlobalCtx},
|
||||
gateway::ip_reassembler::{ComposeIpv4PacketArgs, compose_ipv4_packet},
|
||||
peers::{PeerPacketFilter, peer_manager::PeerManager},
|
||||
tunnel::{
|
||||
@@ -149,7 +150,7 @@ impl UdpNatEntry {
|
||||
let (s, mut r) = channel(128);
|
||||
|
||||
let self_clone = self.clone();
|
||||
let recv_task = ScopedTask::from(tokio::spawn(async move {
|
||||
let recv_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let mut cur_buf = BytesMut::new();
|
||||
loop {
|
||||
if self_clone
|
||||
@@ -194,7 +195,7 @@ impl UdpNatEntry {
|
||||
}));
|
||||
|
||||
let self_clone = self.clone();
|
||||
let send_task = ScopedTask::from(tokio::spawn(async move {
|
||||
let send_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let mut ip_id = 1;
|
||||
while let Some((mut packet, len, src_socket)) = r.recv().await {
|
||||
let SocketAddr::V4(mut src_v4) = src_socket else {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// translated from tailscale #32ce1bdb48078ec4cedaeeb5b1b2ff9c0ef61a49
|
||||
|
||||
use crate::defer;
|
||||
use anyhow::{Context, Result};
|
||||
use dbus::blocking::stdintf::org_freedesktop_dbus::Properties as _;
|
||||
use std::fs;
|
||||
@@ -167,6 +166,7 @@ fn new_os_configurator(_interface_name: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use guarden::defer;
|
||||
use std::io::{self, BufRead, Cursor};
|
||||
|
||||
/// 返回 `resolv.conf` 内容的拥有者("systemd-resolved"、"NetworkManager"、"resolvconf" 或空字符串)
|
||||
|
||||
@@ -9,20 +9,19 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use cidr::{IpCidr, Ipv4Inet};
|
||||
|
||||
use futures::FutureExt;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
#[cfg(feature = "tun")]
|
||||
use tokio::{sync::oneshot, task::JoinSet};
|
||||
#[cfg(feature = "magic-dns")]
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use crate::common::PeerId;
|
||||
use crate::common::acl_processor::AclRuleBuilder;
|
||||
use crate::common::config::ConfigLoader;
|
||||
use crate::common::error::Error;
|
||||
use crate::common::global_ctx::{ArcGlobalCtx, GlobalCtx, GlobalCtxEvent};
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use crate::connector::direct::DirectConnectorManager;
|
||||
use crate::connector::manual::{ConnectorManagerRpcService, ManualConnectorManager};
|
||||
use crate::connector::tcp_hole_punch::TcpHolePunchConnector;
|
||||
@@ -65,6 +64,11 @@ use crate::vpn_portal::{self, VpnPortal};
|
||||
#[cfg(feature = "magic-dns")]
|
||||
use super::dns_server::{MAGIC_DNS_FAKE_IP, runner::DnsRunner};
|
||||
use super::listeners::ListenerManager;
|
||||
use super::public_ipv6_provider::{
|
||||
reconcile_public_ipv6_provider_runtime, run_public_ipv6_provider_reconcile_task,
|
||||
should_run_public_ipv6_provider_reconcile, validate_public_ipv6_config,
|
||||
validate_public_ipv6_config_values,
|
||||
};
|
||||
|
||||
#[cfg(feature = "socks5")]
|
||||
use crate::gateway::socks5::Socks5Server;
|
||||
@@ -135,7 +139,7 @@ type NicCtx = super::virtual_nic::NicCtx;
|
||||
|
||||
#[cfg(feature = "magic-dns")]
|
||||
struct MagicDnsContainer {
|
||||
dns_runner_task: ScopedTask<()>,
|
||||
dns_runner_task: AbortOnDropHandle<()>,
|
||||
dns_runner_cancel_token: CancellationToken,
|
||||
}
|
||||
|
||||
@@ -167,7 +171,7 @@ impl NicCtxContainer {
|
||||
Self {
|
||||
nic_ctx: Some(Box::new(nic_ctx)),
|
||||
magic_dns: Some(MagicDnsContainer {
|
||||
dns_runner_task: task.into(),
|
||||
dns_runner_task: AbortOnDropHandle::new(task),
|
||||
dns_runner_cancel_token: token,
|
||||
}),
|
||||
}
|
||||
@@ -253,11 +257,64 @@ pub struct InstanceConfigPatcher {
|
||||
}
|
||||
|
||||
impl InstanceConfigPatcher {
|
||||
fn parse_ipv6_public_addr_prefix_patch(
|
||||
prefix: Option<&str>,
|
||||
) -> Result<Option<Option<cidr::Ipv6Cidr>>, anyhow::Error> {
|
||||
let Some(prefix) = prefix else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let prefix = prefix.trim();
|
||||
if prefix.is_empty() {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
|
||||
let parsed = prefix
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse ipv6 public address prefix: {prefix}"))?;
|
||||
Ok(Some(Some(parsed)))
|
||||
}
|
||||
|
||||
fn effective_ipv6_for_public_ipv6_validation(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
patch: &crate::proto::api::config::InstanceConfigPatch,
|
||||
_auto_enabled: bool,
|
||||
) -> Option<cidr::Ipv6Inet> {
|
||||
if let Some(ipv6) = patch.ipv6 {
|
||||
return Some(ipv6.into());
|
||||
}
|
||||
|
||||
global_ctx.get_ipv6()
|
||||
}
|
||||
|
||||
fn validate_public_ipv6_patch(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
patch: &crate::proto::api::config::InstanceConfigPatch,
|
||||
) -> Result<Option<Option<cidr::Ipv6Cidr>>, anyhow::Error> {
|
||||
let parsed_prefix =
|
||||
Self::parse_ipv6_public_addr_prefix_patch(patch.ipv6_public_addr_prefix.as_deref())?;
|
||||
|
||||
let auto_enabled = patch
|
||||
.ipv6_public_addr_auto
|
||||
.unwrap_or(global_ctx.config.get_ipv6_public_addr_auto());
|
||||
let provider_enabled = patch
|
||||
.ipv6_public_addr_provider
|
||||
.unwrap_or(global_ctx.config.get_ipv6_public_addr_provider());
|
||||
let prefix =
|
||||
parsed_prefix.unwrap_or_else(|| global_ctx.config.get_ipv6_public_addr_prefix());
|
||||
let ipv6 = Self::effective_ipv6_for_public_ipv6_validation(global_ctx, patch, auto_enabled);
|
||||
|
||||
validate_public_ipv6_config_values(ipv6, provider_enabled, auto_enabled, prefix)?;
|
||||
Ok(parsed_prefix)
|
||||
}
|
||||
|
||||
pub async fn apply_patch(
|
||||
&self,
|
||||
patch: crate::proto::api::config::InstanceConfigPatch,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let patch_for_event = patch.clone();
|
||||
let global_ctx = weak_upgrade(&self.global_ctx)?;
|
||||
let parsed_ipv6_public_addr_prefix = Self::validate_public_ipv6_patch(&global_ctx, &patch)?;
|
||||
|
||||
self.patch_port_forwards(patch.port_forwards).await?;
|
||||
self.patch_acl(patch.acl).await?;
|
||||
@@ -267,7 +324,8 @@ impl InstanceConfigPatcher {
|
||||
self.patch_mapped_listeners(patch.mapped_listeners).await?;
|
||||
self.patch_connector(patch.connectors).await?;
|
||||
|
||||
let global_ctx = weak_upgrade(&self.global_ctx)?;
|
||||
let provider_reconcile_was_running = should_run_public_ipv6_provider_reconcile(&global_ctx);
|
||||
let mut provider_config_changed = false;
|
||||
if let Some(hostname) = patch.hostname {
|
||||
global_ctx.set_hostname(hostname.clone());
|
||||
global_ctx.config.set_hostname(Some(hostname));
|
||||
@@ -282,9 +340,30 @@ impl InstanceConfigPatcher {
|
||||
global_ctx.set_ipv6(Some(ipv6.into()));
|
||||
global_ctx.config.set_ipv6(Some(ipv6.into()));
|
||||
}
|
||||
if let Some(enabled) = patch.ipv6_public_addr_provider {
|
||||
global_ctx.config.set_ipv6_public_addr_provider(enabled);
|
||||
provider_config_changed = true;
|
||||
}
|
||||
if let Some(enabled) = patch.ipv6_public_addr_auto {
|
||||
global_ctx.config.set_ipv6_public_addr_auto(enabled);
|
||||
}
|
||||
if let Some(prefix) = parsed_ipv6_public_addr_prefix {
|
||||
global_ctx.config.set_ipv6_public_addr_prefix(prefix);
|
||||
provider_config_changed = true;
|
||||
}
|
||||
|
||||
global_ctx.issue_event(GlobalCtxEvent::ConfigPatched(patch_for_event));
|
||||
|
||||
if provider_config_changed {
|
||||
reconcile_public_ipv6_provider_runtime(&global_ctx).await;
|
||||
|
||||
let provider_reconcile_should_run =
|
||||
should_run_public_ipv6_provider_reconcile(&global_ctx);
|
||||
if !provider_reconcile_was_running && provider_reconcile_should_run {
|
||||
run_public_ipv6_provider_reconcile_task(&global_ctx);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -558,7 +637,7 @@ pub struct Instance {
|
||||
#[cfg(feature = "socks5")]
|
||||
socks5_server: Arc<Socks5Server>,
|
||||
|
||||
proxy_cidrs_monitor: Option<ScopedTask<()>>,
|
||||
proxy_cidrs_monitor: Option<AbortOnDropHandle<()>>,
|
||||
|
||||
global_ctx: ArcGlobalCtx,
|
||||
}
|
||||
@@ -664,6 +743,12 @@ impl Instance {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prepare_public_ipv6_config(&self) -> Result<(), Error> {
|
||||
validate_public_ipv6_config(&self.global_ctx)?;
|
||||
reconcile_public_ipv6_provider_runtime(&self.global_ctx).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// use a mock nic ctx to consume packets.
|
||||
#[cfg(feature = "tun")]
|
||||
async fn clear_nic_ctx(
|
||||
@@ -932,6 +1017,7 @@ impl Instance {
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Result<(), Error> {
|
||||
self.prepare_public_ipv6_config().await?;
|
||||
self.listener_manager
|
||||
.lock()
|
||||
.await
|
||||
@@ -939,6 +1025,7 @@ impl Instance {
|
||||
.await?;
|
||||
self.listener_manager.lock().await.run().await?;
|
||||
self.peer_manager.run().await?;
|
||||
run_public_ipv6_provider_reconcile_task(&self.global_ctx);
|
||||
|
||||
#[cfg(feature = "tun")]
|
||||
{
|
||||
@@ -1544,7 +1631,9 @@ impl Drop for Instance {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
instance::instance::InstanceRpcServerHook, proto::rpc_impl::standalone::RpcServerHook,
|
||||
common::global_ctx::tests::get_mock_global_ctx,
|
||||
instance::instance::{InstanceConfigPatcher, InstanceRpcServerHook},
|
||||
proto::{api::config::InstanceConfigPatch, rpc_impl::standalone::RpcServerHook},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1665,4 +1754,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_public_ipv6_patch_rejects_non_global_prefix() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
let patch = InstanceConfigPatch {
|
||||
ipv6_public_addr_provider: Some(true),
|
||||
ipv6_public_addr_prefix: Some("fd00::/64".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err =
|
||||
InstanceConfigPatcher::validate_public_ipv6_patch(&global_ctx, &patch).unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("not a valid global unicast IPv6 prefix")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_public_ipv6_patch_allows_enabling_auto_with_manual_ipv6() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
global_ctx.set_ipv6(Some("fd00::1/64".parse().unwrap()));
|
||||
|
||||
let patch = InstanceConfigPatch {
|
||||
ipv6_public_addr_auto: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(InstanceConfigPatcher::validate_public_ipv6_patch(&global_ctx, &patch).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_public_ipv6_patch_ignores_runtime_auto_ipv6_cache() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
global_ctx.config.set_ipv6_public_addr_auto(true);
|
||||
global_ctx.set_ipv6(Some("2001:db8::10/64".parse().unwrap()));
|
||||
|
||||
let patch = InstanceConfigPatch {
|
||||
ipv6_public_addr_provider: Some(true),
|
||||
ipv6_public_addr_prefix: Some("2001:db8:100::/64".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(InstanceConfigPatcher::validate_public_ipv6_patch(&global_ctx, &patch).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ pub mod instance;
|
||||
|
||||
pub mod listeners;
|
||||
|
||||
mod public_ipv6_provider;
|
||||
|
||||
pub mod proxy_cidrs_monitor;
|
||||
|
||||
#[cfg(feature = "tun")]
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::sync::{Arc, Weak};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::common::global_ctx::{ArcGlobalCtx, GlobalCtxEvent};
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use crate::peers::peer_manager::PeerManager;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
/// ProxyCidrsMonitor monitors changes in proxy CIDRs from peer routes
|
||||
/// and emits GlobalCtxEvent::ProxyCidrsUpdated with added/removed diffs.
|
||||
@@ -58,8 +58,8 @@ impl ProxyCidrsMonitor {
|
||||
}
|
||||
|
||||
/// Starts monitoring proxy_cidrs changes and emits events with diffs
|
||||
pub fn start(self) -> ScopedTask<()> {
|
||||
ScopedTask::from(tokio::spawn(async move {
|
||||
pub fn start(self) -> AbortOnDropHandle<()> {
|
||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let mut cur_proxy_cidrs = BTreeSet::new();
|
||||
let mut last_update = None::<Instant>;
|
||||
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use anyhow::Context;
|
||||
use cidr::{Ipv6Cidr, Ipv6Inet};
|
||||
#[cfg(target_os = "linux")]
|
||||
use netlink_packet_route::route::{RouteAddress, RouteAttribute, RouteMessage, RouteType};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::common::ifcfg::{get_interface_index, list_ipv6_route_messages};
|
||||
use crate::common::{
|
||||
error::Error,
|
||||
global_ctx::{ArcGlobalCtx, GlobalCtxEvent},
|
||||
};
|
||||
|
||||
const PUBLIC_IPV6_PROVIDER_RECONCILE_INTERVAL: std::time::Duration =
|
||||
std::time::Duration::from_secs(5);
|
||||
const PUBLIC_IPV6_PROVIDER_RECONCILE_MAX_RETRIES: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum PublicIpv6ProviderRuntimeState {
|
||||
Disabled,
|
||||
Pending(String),
|
||||
Active(Ipv6Cidr),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PublicIpv6ProviderConfigSnapshot {
|
||||
provider_enabled: bool,
|
||||
configured_prefix: Option<Ipv6Cidr>,
|
||||
}
|
||||
|
||||
fn read_public_ipv6_provider_config_snapshot(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
) -> PublicIpv6ProviderConfigSnapshot {
|
||||
PublicIpv6ProviderConfigSnapshot {
|
||||
provider_enabled: global_ctx.config.get_ipv6_public_addr_provider(),
|
||||
configured_prefix: global_ctx.config.get_ipv6_public_addr_prefix(),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_run_public_ipv6_provider_reconcile_task(
|
||||
config: PublicIpv6ProviderConfigSnapshot,
|
||||
) -> bool {
|
||||
config.provider_enabled && config.configured_prefix.is_none()
|
||||
}
|
||||
|
||||
pub(super) fn should_run_public_ipv6_provider_reconcile(global_ctx: &ArcGlobalCtx) -> bool {
|
||||
should_run_public_ipv6_provider_reconcile_task(read_public_ipv6_provider_config_snapshot(
|
||||
global_ctx,
|
||||
))
|
||||
}
|
||||
|
||||
fn is_global_routable_public_ipv6_prefix(prefix: Ipv6Cidr) -> bool {
|
||||
let addr = prefix.first_address();
|
||||
!addr.is_loopback()
|
||||
&& !addr.is_multicast()
|
||||
&& !addr.is_unicast_link_local()
|
||||
&& !addr.is_unique_local()
|
||||
&& !addr.is_unspecified()
|
||||
}
|
||||
|
||||
pub(super) fn validate_public_ipv6_config_values(
|
||||
_ipv6: Option<Ipv6Inet>,
|
||||
provider_enabled: bool,
|
||||
_auto_enabled: bool,
|
||||
prefix: Option<Ipv6Cidr>,
|
||||
) -> Result<(), Error> {
|
||||
if !provider_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ensure_public_ipv6_provider_supported()?;
|
||||
|
||||
if let Some(prefix) = prefix
|
||||
&& !is_global_routable_public_ipv6_prefix(prefix)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"the prefix {} is not a valid global unicast IPv6 prefix; it must be a routable address range, not a private, link-local, or multicast address",
|
||||
prefix
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_public_ipv6_config(global_ctx: &ArcGlobalCtx) -> Result<(), Error> {
|
||||
validate_public_ipv6_config_values(
|
||||
global_ctx.get_ipv6(),
|
||||
global_ctx.config.get_ipv6_public_addr_provider(),
|
||||
global_ctx.config.get_ipv6_public_addr_auto(),
|
||||
global_ctx.config.get_ipv6_public_addr_prefix(),
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_public_ipv6_provider_supported() -> Result<(), Error> {
|
||||
if cfg!(target_os = "linux") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"the provider feature requires Linux; run without --ipv6-public-addr-provider on this node, or move the provider role to a Linux node. client mode (--ipv6-public-addr-auto) works on all platforms"
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
fn public_ipv6_provider_auto_detect_error() -> Error {
|
||||
anyhow::anyhow!(
|
||||
"no public IPv6 prefix found on this system; set --ipv6-public-addr-prefix manually, or check that your ISP has delegated an IPv6 prefix and a default-from route exists in the kernel routing table"
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_linux_proc_bool(path: &Path) -> Result<bool, Error> {
|
||||
let value = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
match value.trim() {
|
||||
"0" => Ok(false),
|
||||
"1" => Ok(true),
|
||||
other => Err(anyhow::anyhow!("unexpected value '{}' in {}", other, path.display()).into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn write_linux_proc_bool(path: &Path, enabled: bool) -> Result<(), Error> {
|
||||
let value = if enabled { "1\n" } else { "0\n" };
|
||||
std::fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ensure_linux_ipv6_forwarding_at_paths(
|
||||
all_path: &Path,
|
||||
default_path: &Path,
|
||||
) -> Result<bool, Error> {
|
||||
let all_enabled = read_linux_proc_bool(all_path)?;
|
||||
let default_enabled = read_linux_proc_bool(default_path)?;
|
||||
let mut changed = false;
|
||||
|
||||
if !all_enabled {
|
||||
write_linux_proc_bool(all_path, true)?;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if !default_enabled {
|
||||
write_linux_proc_bool(default_path, true)?;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if !read_linux_proc_bool(all_path)? || !read_linux_proc_bool(default_path)? {
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to enable Linux IPv6 forwarding in {} and {}",
|
||||
all_path.display(),
|
||||
default_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ensure_linux_ipv6_forwarding() -> Result<bool, Error> {
|
||||
let all_path = Path::new("/proc/sys/net/ipv6/conf/all/forwarding");
|
||||
let default_path = Path::new("/proc/sys/net/ipv6/conf/default/forwarding");
|
||||
|
||||
ensure_linux_ipv6_forwarding_at_paths(all_path, default_path).map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"public IPv6 provider requires Linux IPv6 forwarding; failed to enable net.ipv6.conf.all.forwarding=1 and net.ipv6.conf.default.forwarding=1 automatically: {}. run with sufficient privileges or set them manually",
|
||||
err
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct DetectedIpv6Route {
|
||||
dst: Option<Ipv6Cidr>,
|
||||
src: Option<Ipv6Cidr>,
|
||||
ifindex: Option<u32>,
|
||||
kind: RouteType,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ipv6_cidr_from_route_addr(addr: RouteAddress, prefix_len: u8) -> Option<Ipv6Cidr> {
|
||||
match addr {
|
||||
RouteAddress::Inet6(addr) => Ipv6Cidr::new(addr, prefix_len).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl TryFrom<RouteMessage> for DetectedIpv6Route {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(message: RouteMessage) -> Result<Self, Self::Error> {
|
||||
let dst = message.attributes.iter().find_map(|attr| match attr {
|
||||
RouteAttribute::Destination(addr) => {
|
||||
ipv6_cidr_from_route_addr(addr.clone(), message.header.destination_prefix_length)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let src = message.attributes.iter().find_map(|attr| match attr {
|
||||
RouteAttribute::Source(addr) => {
|
||||
ipv6_cidr_from_route_addr(addr.clone(), message.header.source_prefix_length)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let ifindex = message.attributes.iter().find_map(|attr| match attr {
|
||||
RouteAttribute::Oif(index) => Some(*index),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
dst,
|
||||
src,
|
||||
ifindex,
|
||||
kind: message.header.kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn is_ipv6_default_route(dst: Option<Ipv6Cidr>) -> bool {
|
||||
dst.is_none() || dst == Some(Ipv6Cidr::new(std::net::Ipv6Addr::UNSPECIFIED, 0).unwrap())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect_public_ipv6_prefix_from_routes(
|
||||
routes: &[DetectedIpv6Route],
|
||||
loopback_ifindex: u32,
|
||||
) -> Option<Ipv6Cidr> {
|
||||
routes
|
||||
.iter()
|
||||
.filter_map(|route| {
|
||||
if !is_ipv6_default_route(route.dst) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = route.src?;
|
||||
let wan_ifindex = route.ifindex?;
|
||||
if !is_global_routable_public_ipv6_prefix(prefix) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let delegated = routes.iter().any(|candidate| {
|
||||
candidate.dst == Some(prefix)
|
||||
&& candidate.ifindex.is_some()
|
||||
&& candidate.ifindex != Some(wan_ifindex)
|
||||
&& candidate.ifindex != Some(loopback_ifindex)
|
||||
&& candidate.kind == RouteType::Unicast
|
||||
});
|
||||
|
||||
delegated.then_some(prefix)
|
||||
})
|
||||
.min_by_key(|prefix| prefix.network_length())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn detect_public_ipv6_prefix_linux() -> Result<Option<Ipv6Cidr>, Error> {
|
||||
let routes = list_ipv6_route_messages().with_context(|| "failed to query linux ipv6 routes")?;
|
||||
let routes = routes
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(DetectedIpv6Route::try_from)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let loopback_ifindex =
|
||||
get_interface_index("lo").with_context(|| "failed to resolve linux loopback ifindex")?;
|
||||
|
||||
Ok(detect_public_ipv6_prefix_from_routes(
|
||||
&routes,
|
||||
loopback_ifindex,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
async fn detect_public_ipv6_prefix_linux() -> Result<Option<Ipv6Cidr>, Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn invalid_public_ipv6_prefix_state(
|
||||
prefix: Ipv6Cidr,
|
||||
source: &str,
|
||||
) -> PublicIpv6ProviderRuntimeState {
|
||||
PublicIpv6ProviderRuntimeState::Pending(format!(
|
||||
"the {} prefix {} is not a valid global unicast IPv6 prefix",
|
||||
source, prefix
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn resolve_public_ipv6_provider_runtime_state_linux(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
configured_prefix: Option<Ipv6Cidr>,
|
||||
) -> PublicIpv6ProviderRuntimeState {
|
||||
let _g = global_ctx.net_ns.guard();
|
||||
|
||||
if let Err(err) = ensure_linux_ipv6_forwarding() {
|
||||
return PublicIpv6ProviderRuntimeState::Pending(err.to_string());
|
||||
}
|
||||
|
||||
if let Some(prefix) = configured_prefix {
|
||||
if !is_global_routable_public_ipv6_prefix(prefix) {
|
||||
return invalid_public_ipv6_prefix_state(prefix, "configured");
|
||||
}
|
||||
return PublicIpv6ProviderRuntimeState::Active(prefix);
|
||||
}
|
||||
|
||||
match detect_public_ipv6_prefix_linux().await {
|
||||
Ok(Some(prefix)) if is_global_routable_public_ipv6_prefix(prefix) => {
|
||||
PublicIpv6ProviderRuntimeState::Active(prefix)
|
||||
}
|
||||
Ok(Some(prefix)) => invalid_public_ipv6_prefix_state(prefix, "detected"),
|
||||
Ok(None) => PublicIpv6ProviderRuntimeState::Pending(
|
||||
public_ipv6_provider_auto_detect_error().to_string(),
|
||||
),
|
||||
Err(err) => PublicIpv6ProviderRuntimeState::Pending(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_public_ipv6_provider_runtime_state(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
config: PublicIpv6ProviderConfigSnapshot,
|
||||
) -> PublicIpv6ProviderRuntimeState {
|
||||
if !config.provider_enabled {
|
||||
return PublicIpv6ProviderRuntimeState::Disabled;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return resolve_public_ipv6_provider_runtime_state_linux(
|
||||
global_ctx,
|
||||
config.configured_prefix,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = config.configured_prefix;
|
||||
PublicIpv6ProviderRuntimeState::Pending(
|
||||
ensure_public_ipv6_provider_supported()
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_public_ipv6_provider_runtime_state(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
state: &PublicIpv6ProviderRuntimeState,
|
||||
) -> bool {
|
||||
let next_prefix = match state {
|
||||
PublicIpv6ProviderRuntimeState::Active(prefix) => Some(*prefix),
|
||||
PublicIpv6ProviderRuntimeState::Disabled | PublicIpv6ProviderRuntimeState::Pending(_) => {
|
||||
None
|
||||
}
|
||||
};
|
||||
let prefix_changed = global_ctx.set_advertised_ipv6_public_addr_prefix(next_prefix);
|
||||
|
||||
let next_provider_enabled = matches!(state, PublicIpv6ProviderRuntimeState::Active(_));
|
||||
let feature_changed = {
|
||||
let mut feature_flags = global_ctx.get_feature_flags();
|
||||
if feature_flags.ipv6_public_addr_provider == next_provider_enabled {
|
||||
false
|
||||
} else {
|
||||
feature_flags.ipv6_public_addr_provider = next_provider_enabled;
|
||||
global_ctx.set_feature_flags(feature_flags);
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
prefix_changed || feature_changed
|
||||
}
|
||||
|
||||
fn try_apply_public_ipv6_provider_runtime_state(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
config: PublicIpv6ProviderConfigSnapshot,
|
||||
state: &PublicIpv6ProviderRuntimeState,
|
||||
) -> Option<bool> {
|
||||
(read_public_ipv6_provider_config_snapshot(global_ctx) == config)
|
||||
.then(|| apply_public_ipv6_provider_runtime_state(global_ctx, state))
|
||||
}
|
||||
|
||||
fn current_public_ipv6_provider_runtime_state(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
) -> PublicIpv6ProviderRuntimeState {
|
||||
match (
|
||||
global_ctx.get_feature_flags().ipv6_public_addr_provider,
|
||||
global_ctx.get_advertised_ipv6_public_addr_prefix(),
|
||||
) {
|
||||
(false, _) => PublicIpv6ProviderRuntimeState::Disabled,
|
||||
(true, Some(prefix)) => PublicIpv6ProviderRuntimeState::Active(prefix),
|
||||
(true, None) => PublicIpv6ProviderRuntimeState::Pending(
|
||||
"public IPv6 provider runtime is missing an advertised prefix".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconcile_public_ipv6_provider_runtime_with_state(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
) -> (PublicIpv6ProviderRuntimeState, bool) {
|
||||
for attempt in 0..PUBLIC_IPV6_PROVIDER_RECONCILE_MAX_RETRIES {
|
||||
let config = read_public_ipv6_provider_config_snapshot(global_ctx);
|
||||
let next_state = resolve_public_ipv6_provider_runtime_state(global_ctx, config).await;
|
||||
|
||||
if let Some(changed) =
|
||||
try_apply_public_ipv6_provider_runtime_state(global_ctx, config, &next_state)
|
||||
{
|
||||
return (next_state, changed);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
attempt = attempt + 1,
|
||||
max_retries = PUBLIC_IPV6_PROVIDER_RECONCILE_MAX_RETRIES,
|
||||
"public IPv6 provider config changed during reconcile, retrying"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
max_retries = PUBLIC_IPV6_PROVIDER_RECONCILE_MAX_RETRIES,
|
||||
"skipping public IPv6 provider reconcile because config kept changing"
|
||||
);
|
||||
(
|
||||
current_public_ipv6_provider_runtime_state(global_ctx),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_public_ipv6_provider_runtime(global_ctx: &ArcGlobalCtx) -> bool {
|
||||
reconcile_public_ipv6_provider_runtime_with_state(global_ctx)
|
||||
.await
|
||||
.1
|
||||
}
|
||||
|
||||
pub(super) fn run_public_ipv6_provider_reconcile_task(global_ctx: &ArcGlobalCtx) {
|
||||
if !should_run_public_ipv6_provider_reconcile_task(read_public_ipv6_provider_config_snapshot(
|
||||
global_ctx,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let global_ctx = Arc::downgrade(global_ctx);
|
||||
tokio::spawn(async move {
|
||||
let Some(initial_ctx) = global_ctx.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let mut event_receiver = initial_ctx.subscribe();
|
||||
let mut last_state: Option<PublicIpv6ProviderRuntimeState> = None;
|
||||
|
||||
loop {
|
||||
let Some(global_ctx) = global_ctx.upgrade() else {
|
||||
tracing::debug!("global ctx dropped, stopping public ipv6 provider reconcile");
|
||||
return;
|
||||
};
|
||||
|
||||
let (next_state, changed) =
|
||||
reconcile_public_ipv6_provider_runtime_with_state(&global_ctx).await;
|
||||
if last_state.as_ref() != Some(&next_state) {
|
||||
match &next_state {
|
||||
PublicIpv6ProviderRuntimeState::Disabled if last_state.is_some() => {
|
||||
tracing::info!("public IPv6 provider disabled");
|
||||
}
|
||||
PublicIpv6ProviderRuntimeState::Disabled => {}
|
||||
PublicIpv6ProviderRuntimeState::Pending(reason) => {
|
||||
tracing::warn!(reason = %reason, "public IPv6 provider not ready");
|
||||
}
|
||||
PublicIpv6ProviderRuntimeState::Active(prefix) => {
|
||||
tracing::info!(prefix = %prefix, "public IPv6 provider is active");
|
||||
}
|
||||
}
|
||||
} else if changed {
|
||||
tracing::info!("public IPv6 provider runtime state changed");
|
||||
}
|
||||
last_state = Some(next_state);
|
||||
|
||||
if matches!(
|
||||
last_state.as_ref(),
|
||||
Some(PublicIpv6ProviderRuntimeState::Disabled)
|
||||
) {
|
||||
match event_receiver.recv().await {
|
||||
Ok(GlobalCtxEvent::ConfigPatched(_)) => {}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
event_receiver = event_receiver.resubscribe();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tokio::select! {
|
||||
recv = event_receiver.recv() => match recv {
|
||||
Ok(GlobalCtxEvent::ConfigPatched(_)) => {}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
event_receiver = event_receiver.resubscribe();
|
||||
}
|
||||
},
|
||||
_ = tokio::time::sleep(PUBLIC_IPV6_PROVIDER_RECONCILE_INTERVAL) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::path::PathBuf;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use netlink_packet_route::route::RouteType;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::{
|
||||
DetectedIpv6Route, detect_public_ipv6_prefix_from_routes, detect_public_ipv6_prefix_linux,
|
||||
ensure_linux_ipv6_forwarding_at_paths, ensure_public_ipv6_provider_supported,
|
||||
public_ipv6_provider_auto_detect_error,
|
||||
};
|
||||
|
||||
use super::{
|
||||
PublicIpv6ProviderConfigSnapshot, PublicIpv6ProviderRuntimeState,
|
||||
read_public_ipv6_provider_config_snapshot, should_run_public_ipv6_provider_reconcile_task,
|
||||
try_apply_public_ipv6_provider_runtime_state,
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use super::{ensure_public_ipv6_provider_supported, public_ipv6_provider_auto_detect_error};
|
||||
use crate::common::{
|
||||
config::{ConfigLoader, TomlConfigLoader},
|
||||
global_ctx::GlobalCtx,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_ip(args: &[&str]) {
|
||||
let output = Command::new("ip")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("failed to execute ip process");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ip command failed: {:?}\nstdout: {}\nstderr: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn test_iface_name(tag: &str) -> String {
|
||||
format!("et{}{:x}", tag, std::process::id() & 0xffff)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct ScopedDummyLink {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl ScopedDummyLink {
|
||||
fn new(name: &str) -> Self {
|
||||
let _ = Command::new("ip").args(["link", "del", name]).output();
|
||||
run_ip(&["link", "add", name, "type", "dummy"]);
|
||||
run_ip(&["link", "set", name, "up"]);
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for ScopedDummyLink {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("ip")
|
||||
.args(["link", "del", &self.name])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn temp_forwarding_paths(
|
||||
all_value: &str,
|
||||
default_value: &str,
|
||||
) -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let all_path = dir.path().join("all_forwarding");
|
||||
let default_path = dir.path().join("default_forwarding");
|
||||
fs::write(&all_path, all_value).unwrap();
|
||||
fs::write(&default_path, default_value).unwrap();
|
||||
(dir, all_path, default_path)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn route(
|
||||
dst: Option<&str>,
|
||||
src: Option<&str>,
|
||||
ifindex: Option<u32>,
|
||||
kind: RouteType,
|
||||
) -> DetectedIpv6Route {
|
||||
DetectedIpv6Route {
|
||||
dst: dst.map(|cidr| cidr.parse().unwrap()),
|
||||
src: src.map(|cidr| cidr.parse().unwrap()),
|
||||
ifindex,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn test_detect_public_ipv6_prefix_from_routes_selects_delegated_prefix() {
|
||||
let routes = vec![
|
||||
route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast),
|
||||
route(Some("2001:db8:1::/56"), None, Some(3), RouteType::Unicast),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
detect_public_ipv6_prefix_from_routes(&routes, 1),
|
||||
Some("2001:db8:1::/56".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn test_detect_public_ipv6_prefix_from_routes_rejects_non_public_prefixes() {
|
||||
let routes = vec![
|
||||
route(Some("::/0"), Some("fd00::/48"), Some(2), RouteType::Unicast),
|
||||
route(Some("fd00::/48"), None, Some(3), RouteType::Unicast),
|
||||
route(None, Some("fe80::/64"), Some(4), RouteType::Unicast),
|
||||
route(Some("fe80::/64"), None, Some(5), RouteType::Unicast),
|
||||
route(None, Some("ff00::/8"), Some(6), RouteType::Unicast),
|
||||
route(Some("ff00::/8"), None, Some(7), RouteType::Unicast),
|
||||
route(None, Some("::/0"), Some(8), RouteType::Unicast),
|
||||
route(Some("::/0"), None, Some(9), RouteType::Unicast),
|
||||
];
|
||||
|
||||
assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn test_detect_public_ipv6_prefix_from_routes_requires_delegated_route() {
|
||||
let routes = vec![route(
|
||||
None,
|
||||
Some("2001:db8:1::/56"),
|
||||
Some(2),
|
||||
RouteType::Unicast,
|
||||
)];
|
||||
|
||||
assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn test_detect_public_ipv6_prefix_from_routes_rejects_loopback_delegation() {
|
||||
let routes = vec![
|
||||
route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast),
|
||||
route(Some("2001:db8:1::/56"), None, Some(1), RouteType::Unicast),
|
||||
];
|
||||
|
||||
assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn test_detect_public_ipv6_prefix_from_routes_prefers_shortest_prefix() {
|
||||
let routes = vec![
|
||||
route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast),
|
||||
route(Some("2001:db8:1::/56"), None, Some(3), RouteType::Unicast),
|
||||
route(None, Some("2001:db8::/48"), Some(4), RouteType::Unicast),
|
||||
route(Some("2001:db8::/48"), None, Some(5), RouteType::Unicast),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
detect_public_ipv6_prefix_from_routes(&routes, 1),
|
||||
Some("2001:db8::/48".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn test_detect_public_ipv6_prefix_from_routes_rejects_non_unicast_delegation() {
|
||||
let routes = vec![
|
||||
route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast),
|
||||
route(Some("2001:db8:1::/56"), None, Some(3), RouteType::BlackHole),
|
||||
];
|
||||
|
||||
assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_public_ipv6_provider_auto_detect_error_mentions_manual_prefix() {
|
||||
let err = public_ipv6_provider_auto_detect_error();
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(msg.contains("IPv6 prefix"), "{}", msg);
|
||||
assert!(msg.contains("ipv6-public-addr-prefix"), "{}", msg);
|
||||
}
|
||||
|
||||
fn test_global_ctx() -> Arc<GlobalCtx> {
|
||||
Arc::new(GlobalCtx::new(TomlConfigLoader::default()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_public_ipv6_provider_config_snapshot_reads_provider_fields() {
|
||||
let global_ctx = test_global_ctx();
|
||||
let prefix = "2001:db8::/48".parse().unwrap();
|
||||
global_ctx.config.set_ipv6_public_addr_provider(true);
|
||||
global_ctx.config.set_ipv6_public_addr_prefix(Some(prefix));
|
||||
|
||||
assert_eq!(
|
||||
read_public_ipv6_provider_config_snapshot(&global_ctx),
|
||||
PublicIpv6ProviderConfigSnapshot {
|
||||
provider_enabled: true,
|
||||
configured_prefix: Some(prefix),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reconcile_task_only_runs_for_auto_detect_provider() {
|
||||
assert!(!should_run_public_ipv6_provider_reconcile_task(
|
||||
PublicIpv6ProviderConfigSnapshot {
|
||||
provider_enabled: false,
|
||||
configured_prefix: None,
|
||||
}
|
||||
));
|
||||
assert!(!should_run_public_ipv6_provider_reconcile_task(
|
||||
PublicIpv6ProviderConfigSnapshot {
|
||||
provider_enabled: true,
|
||||
configured_prefix: Some("2001:db8::/48".parse().unwrap()),
|
||||
}
|
||||
));
|
||||
assert!(should_run_public_ipv6_provider_reconcile_task(
|
||||
PublicIpv6ProviderConfigSnapshot {
|
||||
provider_enabled: true,
|
||||
configured_prefix: None,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_try_apply_public_ipv6_provider_runtime_state_rejects_stale_config() {
|
||||
let global_ctx = test_global_ctx();
|
||||
let prefix = "2001:db8::/48".parse().unwrap();
|
||||
let config = PublicIpv6ProviderConfigSnapshot {
|
||||
provider_enabled: true,
|
||||
configured_prefix: Some(prefix),
|
||||
};
|
||||
|
||||
global_ctx.config.set_ipv6_public_addr_provider(false);
|
||||
global_ctx.config.set_ipv6_public_addr_prefix(None);
|
||||
|
||||
let changed = try_apply_public_ipv6_provider_runtime_state(
|
||||
&global_ctx,
|
||||
config,
|
||||
&PublicIpv6ProviderRuntimeState::Active(prefix),
|
||||
);
|
||||
|
||||
assert_eq!(changed, None);
|
||||
assert_eq!(global_ctx.get_advertised_ipv6_public_addr_prefix(), None);
|
||||
assert!(!global_ctx.get_feature_flags().ipv6_public_addr_provider);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_try_apply_public_ipv6_provider_runtime_state_applies_matching_config() {
|
||||
let global_ctx = test_global_ctx();
|
||||
let prefix = "2001:db8::/48".parse().unwrap();
|
||||
global_ctx.config.set_ipv6_public_addr_provider(true);
|
||||
global_ctx.config.set_ipv6_public_addr_prefix(Some(prefix));
|
||||
let config = read_public_ipv6_provider_config_snapshot(&global_ctx);
|
||||
|
||||
let changed = try_apply_public_ipv6_provider_runtime_state(
|
||||
&global_ctx,
|
||||
config,
|
||||
&PublicIpv6ProviderRuntimeState::Active(prefix),
|
||||
);
|
||||
|
||||
assert_eq!(changed, Some(true));
|
||||
assert_eq!(
|
||||
global_ctx.get_advertised_ipv6_public_addr_prefix(),
|
||||
Some(prefix)
|
||||
);
|
||||
assert!(global_ctx.get_feature_flags().ipv6_public_addr_provider);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn test_public_ipv6_provider_platform_check_accepts_linux() {
|
||||
assert!(ensure_public_ipv6_provider_supported().is_ok());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn test_ensure_linux_ipv6_forwarding_enables_all_and_default() {
|
||||
let (_dir, all_path, default_path) = temp_forwarding_paths("0\n", "0\n");
|
||||
|
||||
let changed = ensure_linux_ipv6_forwarding_at_paths(&all_path, &default_path).unwrap();
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(fs::read_to_string(&all_path).unwrap(), "1\n");
|
||||
assert_eq!(fs::read_to_string(&default_path).unwrap(), "1\n");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn test_ensure_linux_ipv6_forwarding_is_noop_when_already_enabled() {
|
||||
let (_dir, all_path, default_path) = temp_forwarding_paths("1\n", "1\n");
|
||||
|
||||
let changed = ensure_linux_ipv6_forwarding_at_paths(&all_path, &default_path).unwrap();
|
||||
|
||||
assert!(!changed);
|
||||
assert_eq!(fs::read_to_string(&all_path).unwrap(), "1\n");
|
||||
assert_eq!(fs::read_to_string(&default_path).unwrap(), "1\n");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn test_public_ipv6_provider_platform_check_reports_linux_only() {
|
||||
let err = ensure_public_ipv6_provider_supported().unwrap_err();
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(msg.contains("Linux"), "{}", msg);
|
||||
assert!(msg.contains("ipv6-public-addr-auto"), "{}", msg);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[serial_test::serial]
|
||||
#[tokio::test]
|
||||
async fn test_detect_public_ipv6_prefix_linux_reads_netlink_routes_from_kernel() {
|
||||
let wan_if = test_iface_name("dw");
|
||||
let lan_if = test_iface_name("dl");
|
||||
let _wan = ScopedDummyLink::new(&wan_if);
|
||||
let _lan = ScopedDummyLink::new(&lan_if);
|
||||
|
||||
run_ip(&[
|
||||
"-6",
|
||||
"addr",
|
||||
"add",
|
||||
"2001:db8:100:ffff::1/64",
|
||||
"dev",
|
||||
&wan_if,
|
||||
]);
|
||||
run_ip(&[
|
||||
"-6",
|
||||
"route",
|
||||
"add",
|
||||
"default",
|
||||
"from",
|
||||
"2001:db8:100::/56",
|
||||
"dev",
|
||||
&wan_if,
|
||||
]);
|
||||
run_ip(&["-6", "route", "add", "2001:db8:100::/56", "dev", &lan_if]);
|
||||
|
||||
assert_eq!(
|
||||
detect_public_ipv6_prefix_linux().await.unwrap(),
|
||||
Some("2001:db8:100::/56".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[serial_test::serial]
|
||||
#[tokio::test]
|
||||
async fn test_detect_public_ipv6_prefix_linux_prefers_shortest_prefix_from_kernel() {
|
||||
let wan_if_1 = test_iface_name("sw1");
|
||||
let lan_if_1 = test_iface_name("sl1");
|
||||
let wan_if_2 = test_iface_name("sw2");
|
||||
let lan_if_2 = test_iface_name("sl2");
|
||||
let _wan_1 = ScopedDummyLink::new(&wan_if_1);
|
||||
let _lan_1 = ScopedDummyLink::new(&lan_if_1);
|
||||
let _wan_2 = ScopedDummyLink::new(&wan_if_2);
|
||||
let _lan_2 = ScopedDummyLink::new(&lan_if_2);
|
||||
|
||||
run_ip(&[
|
||||
"-6",
|
||||
"addr",
|
||||
"add",
|
||||
"2001:db8:3000:ffff::1/64",
|
||||
"dev",
|
||||
&wan_if_1,
|
||||
]);
|
||||
run_ip(&[
|
||||
"-6",
|
||||
"route",
|
||||
"add",
|
||||
"default",
|
||||
"from",
|
||||
"2001:db8:3000::/56",
|
||||
"dev",
|
||||
&wan_if_1,
|
||||
]);
|
||||
run_ip(&["-6", "route", "add", "2001:db8:3000::/56", "dev", &lan_if_1]);
|
||||
|
||||
run_ip(&["-6", "addr", "add", "2001:db9:ffff::1/64", "dev", &wan_if_2]);
|
||||
run_ip(&[
|
||||
"-6",
|
||||
"route",
|
||||
"add",
|
||||
"default",
|
||||
"from",
|
||||
"2001:db9::/48",
|
||||
"dev",
|
||||
&wan_if_2,
|
||||
]);
|
||||
run_ip(&["-6", "route", "add", "2001:db9::/48", "dev", &lan_if_2]);
|
||||
|
||||
assert_eq!(
|
||||
detect_public_ipv6_prefix_linux().await.unwrap(),
|
||||
Some("2001:db9::/48".parse().unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -735,9 +735,26 @@ impl VirtualNic {
|
||||
}
|
||||
|
||||
pub async fn add_ipv6_route(&self, address: Ipv6Addr, cidr: u8) -> Result<(), Error> {
|
||||
self.add_ipv6_route_with_cost(address, cidr, None).await
|
||||
}
|
||||
|
||||
pub async fn add_ipv6_route_with_cost(
|
||||
&self,
|
||||
address: Ipv6Addr,
|
||||
cidr: u8,
|
||||
cost: Option<i32>,
|
||||
) -> Result<(), Error> {
|
||||
let _g = self.global_ctx.net_ns.guard();
|
||||
self.ifcfg
|
||||
.add_ipv6_route(self.ifname(), address, cidr, None)
|
||||
.add_ipv6_route(self.ifname(), address, cidr, cost)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_ipv6_route(&self, address: Ipv6Addr, cidr: u8) -> Result<(), Error> {
|
||||
let _g = self.global_ctx.net_ns.guard();
|
||||
self.ifcfg
|
||||
.remove_ipv6_route(self.ifname(), address, cidr)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -903,7 +920,7 @@ impl NicCtx {
|
||||
}
|
||||
let src_ipv6 = ipv6.get_source();
|
||||
let dst_ipv6 = ipv6.get_destination();
|
||||
let my_ipv6 = mgr.get_global_ctx().get_ipv6().map(|x| x.address());
|
||||
let is_local_src = mgr.get_global_ctx().is_ip_local_ipv6(&src_ipv6);
|
||||
tracing::trace!(
|
||||
?ret,
|
||||
?src_ipv6,
|
||||
@@ -911,14 +928,14 @@ impl NicCtx {
|
||||
"[USER_PACKET] recv new packet from tun device and forward to peers."
|
||||
);
|
||||
|
||||
if src_ipv6.is_unicast_link_local() && Some(src_ipv6) != my_ipv6 {
|
||||
if src_ipv6.is_unicast_link_local() && !is_local_src {
|
||||
// do not route link local packet to other nodes unless the address is assigned by user
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: use zero-copy
|
||||
let send_ret = mgr
|
||||
.send_msg_by_ip(ret, IpAddr::V6(dst_ipv6), Some(src_ipv6) == my_ipv6)
|
||||
.send_msg_by_ip(ret, IpAddr::V6(dst_ipv6), is_local_src)
|
||||
.await;
|
||||
if send_ret.is_err() {
|
||||
tracing::trace!(?send_ret, "[USER_PACKET] send_msg failed")
|
||||
@@ -1039,6 +1056,44 @@ impl NicCtx {
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_public_ipv6_route_changes(
|
||||
ifcfg: &impl IfConfiguerTrait,
|
||||
ifname: &str,
|
||||
net_ns: &crate::common::netns::NetNS,
|
||||
cur_routes: &mut BTreeSet<cidr::Ipv6Inet>,
|
||||
added: Vec<cidr::Ipv6Inet>,
|
||||
removed: Vec<cidr::Ipv6Inet>,
|
||||
) {
|
||||
for route in removed {
|
||||
if !cur_routes.contains(&route) {
|
||||
continue;
|
||||
}
|
||||
let _g = net_ns.guard();
|
||||
let ret = ifcfg
|
||||
.remove_ipv6_route(ifname, route.address(), route.network_length())
|
||||
.await;
|
||||
if ret.is_err() {
|
||||
tracing::trace!(route = ?route, err = ?ret, "remove public ipv6 route failed");
|
||||
}
|
||||
cur_routes.remove(&route);
|
||||
}
|
||||
|
||||
for route in added {
|
||||
if cur_routes.contains(&route) {
|
||||
continue;
|
||||
}
|
||||
let _g = net_ns.guard();
|
||||
let ret = ifcfg
|
||||
.add_ipv6_route(ifname, route.address(), route.network_length(), None)
|
||||
.await;
|
||||
if ret.is_err() {
|
||||
tracing::trace!(route = ?route, err = ?ret, "add public ipv6 route failed");
|
||||
} else {
|
||||
cur_routes.insert(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_proxy_cidrs_route_updater(&mut self) -> Result<(), Error> {
|
||||
let Some(peer_mgr) = self.peer_mgr.upgrade() else {
|
||||
return Err(anyhow::anyhow!("peer manager not available").into());
|
||||
@@ -1114,6 +1169,137 @@ impl NicCtx {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_public_ipv6_route_updater(&mut self) -> Result<(), Error> {
|
||||
let Some(peer_mgr) = self.peer_mgr.upgrade() else {
|
||||
return Err(anyhow::anyhow!("peer manager not available").into());
|
||||
};
|
||||
let global_ctx = self.global_ctx.clone();
|
||||
let net_ns = self.global_ctx.net_ns.clone();
|
||||
let nic = self.nic.lock().await;
|
||||
let ifcfg = nic.get_ifcfg();
|
||||
let ifname = nic.ifname().to_owned();
|
||||
let mut event_receiver = global_ctx.subscribe();
|
||||
|
||||
self.tasks.spawn(async move {
|
||||
let mut cur_routes = BTreeSet::<cidr::Ipv6Inet>::new();
|
||||
let initial_routes = peer_mgr.list_public_ipv6_routes().await;
|
||||
let initial_added = initial_routes.iter().copied().collect::<Vec<_>>();
|
||||
Self::apply_public_ipv6_route_changes(
|
||||
&ifcfg,
|
||||
&ifname,
|
||||
&net_ns,
|
||||
&mut cur_routes,
|
||||
initial_added,
|
||||
Vec::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
loop {
|
||||
let event = match event_receiver.recv().await {
|
||||
Ok(event) => event,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
event_receiver = event_receiver.resubscribe();
|
||||
let latest = peer_mgr.list_public_ipv6_routes().await;
|
||||
let added = latest.difference(&cur_routes).copied().collect::<Vec<_>>();
|
||||
let removed = cur_routes.difference(&latest).copied().collect::<Vec<_>>();
|
||||
GlobalCtxEvent::PublicIpv6RoutesUpdated(added, removed)
|
||||
}
|
||||
};
|
||||
|
||||
let (added, removed) = match event {
|
||||
GlobalCtxEvent::PublicIpv6RoutesUpdated(added, removed) => (added, removed),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
Self::apply_public_ipv6_route_changes(
|
||||
&ifcfg,
|
||||
&ifname,
|
||||
&net_ns,
|
||||
&mut cur_routes,
|
||||
added,
|
||||
removed,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_public_ipv6_addr_updater(&mut self) -> Result<(), Error> {
|
||||
let Some(peer_mgr) = self.peer_mgr.upgrade() else {
|
||||
return Err(anyhow::anyhow!("peer manager not available").into());
|
||||
};
|
||||
let global_ctx = self.global_ctx.clone();
|
||||
let nic = self.nic.clone();
|
||||
let mut event_receiver = global_ctx.subscribe();
|
||||
|
||||
self.tasks.spawn(async move {
|
||||
let mut current_addr = peer_mgr.get_my_public_ipv6_addr().await;
|
||||
if let Some(addr) = current_addr {
|
||||
let nic = nic.lock().await;
|
||||
if let Err(err) = nic.link_up().await {
|
||||
tracing::warn!(?err, "failed to bring public ipv6 nic link up");
|
||||
}
|
||||
if let Err(err) = nic.add_ipv6(addr.address(), addr.network_length() as i32).await {
|
||||
tracing::warn!(addr = ?addr, ?err, "failed to add public ipv6 address");
|
||||
}
|
||||
if let Err(err) = nic
|
||||
.add_ipv6_route_with_cost(Ipv6Addr::UNSPECIFIED, 0, Some(5))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(route = %Ipv6Addr::UNSPECIFIED, prefix = 0, ?err, "failed to add default public ipv6 route");
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let event = match event_receiver.recv().await {
|
||||
Ok(event) => event,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
event_receiver = event_receiver.resubscribe();
|
||||
let latest = peer_mgr.get_my_public_ipv6_addr().await;
|
||||
GlobalCtxEvent::PublicIpv6Changed(current_addr, latest)
|
||||
}
|
||||
};
|
||||
|
||||
let (old, new) = match event {
|
||||
GlobalCtxEvent::PublicIpv6Changed(old, new) => (old, new),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
current_addr = new;
|
||||
let nic = nic.lock().await;
|
||||
if let Err(err) = nic.link_up().await {
|
||||
tracing::warn!(?err, "failed to bring public ipv6 nic link up");
|
||||
}
|
||||
if let Some(old) = old {
|
||||
if let Err(err) = nic.remove_ipv6_route(Ipv6Addr::UNSPECIFIED, 0).await {
|
||||
tracing::warn!(route = %Ipv6Addr::UNSPECIFIED, prefix = 0, ?err, "failed to remove default public ipv6 route");
|
||||
}
|
||||
if let Err(err) = nic.remove_ipv6(Some(old)).await {
|
||||
tracing::warn!(addr = ?old, ?err, "failed to remove old public ipv6 address");
|
||||
}
|
||||
}
|
||||
if let Some(new) = new {
|
||||
if let Err(err) = nic.add_ipv6(new.address(), new.network_length() as i32).await
|
||||
{
|
||||
tracing::warn!(addr = ?new, ?err, "failed to add public ipv6 address");
|
||||
}
|
||||
if let Err(err) = nic
|
||||
.add_ipv6_route_with_cost(Ipv6Addr::UNSPECIFIED, 0, Some(5))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(route = %Ipv6Addr::UNSPECIFIED, prefix = 0, ?err, "failed to add default public ipv6 route");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
&mut self,
|
||||
ipv4_addr: Option<cidr::Ipv4Inet>,
|
||||
@@ -1169,6 +1355,10 @@ impl NicCtx {
|
||||
}
|
||||
|
||||
self.run_proxy_cidrs_route_updater().await?;
|
||||
self.run_public_ipv6_route_updater().await?;
|
||||
// Keep the updater running so runtime config patches can enable auto mode
|
||||
// without recreating the NIC.
|
||||
self.run_public_ipv6_addr_updater().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use dashmap::DashMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
config::{ConfigFileControl, ConfigLoader, TomlConfigLoader},
|
||||
config::{ConfigFileControl, ConfigLoader, ConfigSource, TomlConfigLoader},
|
||||
global_ctx::{EventBusSubscriber, GlobalCtxEvent},
|
||||
log,
|
||||
scoped_task::ScopedTask,
|
||||
},
|
||||
launcher::{NetworkInstance, NetworkInstanceRunningInfo},
|
||||
proto::{self},
|
||||
@@ -27,7 +27,7 @@ impl Drop for DaemonGuard {
|
||||
|
||||
pub struct NetworkInstanceManager {
|
||||
instance_map: Arc<DashMap<uuid::Uuid, NetworkInstance>>,
|
||||
instance_stop_tasks: Arc<DashMap<uuid::Uuid, ScopedTask<()>>>,
|
||||
instance_stop_tasks: Arc<DashMap<uuid::Uuid, AbortOnDropHandle<()>>>,
|
||||
stop_check_notifier: Arc<tokio::sync::Notify>,
|
||||
instance_error_messages: Arc<DashMap<uuid::Uuid, String>>,
|
||||
config_dir: Option<PathBuf>,
|
||||
@@ -78,12 +78,12 @@ impl NetworkInstanceManager {
|
||||
let stop_check_notifier = self.stop_check_notifier.clone();
|
||||
self.instance_stop_tasks.insert(
|
||||
instance_id,
|
||||
ScopedTask::from(tokio::spawn(async move {
|
||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let Some(instance_stop_notifier) = instance_stop_notifier else {
|
||||
return;
|
||||
};
|
||||
let _t = instance_event_receiver
|
||||
.map(|event| ScopedTask::from(handle_event(instance_id, event)));
|
||||
.map(|event| AbortOnDropHandle::new(handle_event(instance_id, event)));
|
||||
instance_stop_notifier.notified().await;
|
||||
if let Some(instance) = instance_map.get(&instance_id)
|
||||
&& let Some(error) = instance.get_latest_error_msg()
|
||||
@@ -217,6 +217,15 @@ impl NetworkInstanceManager {
|
||||
.map(|instance| instance.value().get_config_file_control().clone())
|
||||
}
|
||||
|
||||
pub fn get_instance_network_config_source(
|
||||
&self,
|
||||
instance_id: &uuid::Uuid,
|
||||
) -> Option<ConfigSource> {
|
||||
self.instance_map
|
||||
.get(instance_id)
|
||||
.map(|instance| instance.value().get_network_config_source())
|
||||
}
|
||||
|
||||
pub fn get_instance_service(
|
||||
&self,
|
||||
instance_id: &uuid::Uuid,
|
||||
@@ -355,6 +364,21 @@ fn handle_event(
|
||||
event!(info, category: "CONNECTION", local, remote, err, "[{}] connection error", instance_id);
|
||||
}
|
||||
|
||||
GlobalCtxEvent::ListenerPortMappingEstablished {
|
||||
local_listener,
|
||||
mapped_listener,
|
||||
backend,
|
||||
} => {
|
||||
event!(
|
||||
info,
|
||||
%local_listener,
|
||||
%mapped_listener,
|
||||
backend,
|
||||
"[{}] listener port mapping established",
|
||||
instance_id
|
||||
);
|
||||
}
|
||||
|
||||
GlobalCtxEvent::TunDeviceReady(dev) => {
|
||||
event!(info, dev, "[{}] tun device ready", instance_id);
|
||||
}
|
||||
@@ -411,6 +435,20 @@ fn handle_event(
|
||||
event!(info, ?ip, "[{}] dhcp ip conflict", instance_id);
|
||||
}
|
||||
|
||||
GlobalCtxEvent::PublicIpv6Changed(old, new) => {
|
||||
event!(info, ?old, ?new, "[{}] public ipv6 changed", instance_id);
|
||||
}
|
||||
|
||||
GlobalCtxEvent::PublicIpv6RoutesUpdated(added, removed) => {
|
||||
event!(
|
||||
info,
|
||||
?added,
|
||||
?removed,
|
||||
"[{}] public ipv6 routes updated",
|
||||
instance_id
|
||||
);
|
||||
}
|
||||
|
||||
GlobalCtxEvent::PortForwardAdded(cfg) => {
|
||||
event!(
|
||||
info,
|
||||
|
||||
+45
-17
@@ -1,4 +1,7 @@
|
||||
use crate::common::config::{ConfigFileControl, PortForwardConfig, process_secure_mode_cfg};
|
||||
use crate::common::config::{
|
||||
ConfigFileControl, ConfigSource, PortForwardConfig, parse_mapped_listener_urls,
|
||||
process_secure_mode_cfg,
|
||||
};
|
||||
use crate::proto::api::{self, manage};
|
||||
use crate::proto::rpc_types::controller::BaseController;
|
||||
use crate::rpc_service::InstanceRpcService;
|
||||
@@ -434,6 +437,10 @@ impl NetworkInstance {
|
||||
&self.config_file_control
|
||||
}
|
||||
|
||||
pub fn get_network_config_source(&self) -> ConfigSource {
|
||||
self.config.get_network_config_source()
|
||||
}
|
||||
|
||||
pub fn get_latest_error_msg(&self) -> Option<String> {
|
||||
if let Some(launcher) = self.launcher.as_ref() {
|
||||
launcher.error_msg.read().unwrap().clone()
|
||||
@@ -665,22 +672,8 @@ impl NetworkConfig {
|
||||
}
|
||||
|
||||
if !self.mapped_listeners.is_empty() {
|
||||
cfg.set_mapped_listeners(Some(
|
||||
self.mapped_listeners
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.parse()
|
||||
.with_context(|| format!("mapped listener is not a valid url: {}", s))
|
||||
.unwrap()
|
||||
})
|
||||
.map(|s: url::Url| {
|
||||
if s.port().is_none() {
|
||||
panic!("mapped listener port is missing: {}", s);
|
||||
}
|
||||
s
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
let mapped_listeners = parse_mapped_listener_urls(&self.mapped_listeners)?;
|
||||
cfg.set_mapped_listeners(Some(mapped_listeners));
|
||||
}
|
||||
|
||||
if let Some(credential_file) = self
|
||||
@@ -721,6 +714,24 @@ impl NetworkConfig {
|
||||
flags.use_smoltcp = use_smoltcp;
|
||||
}
|
||||
|
||||
if let Some(ipv6_public_addr_provider) = self.ipv6_public_addr_provider {
|
||||
cfg.set_ipv6_public_addr_provider(ipv6_public_addr_provider);
|
||||
}
|
||||
|
||||
if let Some(ipv6_public_addr_auto) = self.ipv6_public_addr_auto {
|
||||
cfg.set_ipv6_public_addr_auto(ipv6_public_addr_auto);
|
||||
}
|
||||
|
||||
if let Some(ipv6_public_addr_prefix) = self
|
||||
.ipv6_public_addr_prefix
|
||||
.as_ref()
|
||||
.filter(|prefix| !prefix.is_empty())
|
||||
{
|
||||
cfg.set_ipv6_public_addr_prefix(Some(ipv6_public_addr_prefix.parse().with_context(
|
||||
|| format!("failed to parse ipv6 public address prefix: {ipv6_public_addr_prefix}"),
|
||||
)?));
|
||||
}
|
||||
|
||||
if let Some(disable_ipv6) = self.disable_ipv6 {
|
||||
flags.enable_ipv6 = !disable_ipv6;
|
||||
}
|
||||
@@ -801,6 +812,10 @@ impl NetworkConfig {
|
||||
flags.disable_udp_hole_punching = disable_udp_hole_punching;
|
||||
}
|
||||
|
||||
if let Some(disable_upnp) = self.disable_upnp {
|
||||
flags.disable_upnp = disable_upnp;
|
||||
}
|
||||
|
||||
if let Some(disable_sym_hole_punching) = self.disable_sym_hole_punching {
|
||||
flags.disable_sym_hole_punching = disable_sym_hole_punching;
|
||||
}
|
||||
@@ -866,6 +881,17 @@ impl NetworkConfig {
|
||||
result.network_length = Some(ipv4.network_length() as i32);
|
||||
}
|
||||
|
||||
if config.get_ipv6_public_addr_provider() != default_config.get_ipv6_public_addr_provider()
|
||||
{
|
||||
result.ipv6_public_addr_provider = Some(config.get_ipv6_public_addr_provider());
|
||||
}
|
||||
if config.get_ipv6_public_addr_auto() != default_config.get_ipv6_public_addr_auto() {
|
||||
result.ipv6_public_addr_auto = Some(config.get_ipv6_public_addr_auto());
|
||||
}
|
||||
result.ipv6_public_addr_prefix = config
|
||||
.get_ipv6_public_addr_prefix()
|
||||
.map(|prefix| prefix.to_string());
|
||||
|
||||
let peers = config.get_peers();
|
||||
result.networking_method = Some(NetworkingMethod::Manual as i32);
|
||||
if !peers.is_empty() {
|
||||
@@ -963,6 +989,7 @@ impl NetworkConfig {
|
||||
result.disable_encryption = Some(!flags.enable_encryption);
|
||||
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
|
||||
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
|
||||
result.disable_upnp = Some(flags.disable_upnp);
|
||||
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
|
||||
result.enable_magic_dns = Some(flags.accept_dns);
|
||||
result.mtu = Some(flags.mtu as i32);
|
||||
@@ -1230,6 +1257,7 @@ mod tests {
|
||||
flags.enable_encryption = rng.gen_bool(0.8);
|
||||
flags.disable_tcp_hole_punching = rng.gen_bool(0.2);
|
||||
flags.disable_udp_hole_punching = rng.gen_bool(0.2);
|
||||
flags.disable_upnp = rng.gen_bool(0.2);
|
||||
flags.accept_dns = rng.gen_bool(0.6);
|
||||
flags.mtu = rng.gen_range(1200..1500);
|
||||
flags.private_mode = rng.gen_bool(0.3);
|
||||
|
||||
@@ -13,7 +13,6 @@ use pnet::packet::{
|
||||
Packet as _, ip::IpNextHeaderProtocols, ipv4::Ipv4Packet, tcp::TcpPacket, udp::UdpPacket,
|
||||
};
|
||||
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use crate::proto::acl::{AclStats, Protocol};
|
||||
use crate::tunnel::packet_def::PacketType;
|
||||
use crate::{
|
||||
@@ -21,6 +20,7 @@ use crate::{
|
||||
proto::acl::{Acl, Action, ChainType},
|
||||
tunnel::packet_def::ZCPacket,
|
||||
};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||
struct OutboundAllowRecord {
|
||||
@@ -63,7 +63,7 @@ pub struct AclFilter {
|
||||
// Track allowed outbound packets and automatically allow their corresponding inbound response
|
||||
// packets, even if they would normally be dropped by ACL rules
|
||||
outbound_allow_records: Arc<DashMap<OutboundAllowRecord, Instant>>,
|
||||
clean_task: ScopedTask<()>,
|
||||
clean_task: AbortOnDropHandle<()>,
|
||||
}
|
||||
|
||||
impl Default for AclFilter {
|
||||
@@ -80,14 +80,13 @@ impl AclFilter {
|
||||
acl_processor: ArcSwap::from(Arc::new(AclProcessor::new(Acl::default()))),
|
||||
acl_enabled: Arc::new(AtomicBool::new(false)),
|
||||
outbound_allow_records,
|
||||
clean_task: tokio::spawn(async move {
|
||||
clean_task: AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let max_life = std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
record_clone.retain(|_, v| v.elapsed() < max_life);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +94,8 @@ impl AclFilter {
|
||||
/// Preserves connection tracking and rate limiting state across reloads
|
||||
/// Now lock-free and doesn't require &mut self!
|
||||
pub fn reload_rules(&self, acl_config: Option<&Acl>) {
|
||||
self.outbound_allow_records.clear();
|
||||
|
||||
let Some(acl_config) = acl_config else {
|
||||
self.acl_enabled.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
@@ -293,13 +294,33 @@ impl AclFilter {
|
||||
processor.increment_stat(AclStatKey::PacketsTotal);
|
||||
}
|
||||
|
||||
fn classify_chain_type(
|
||||
is_in: bool,
|
||||
packet_info: &PacketInfo,
|
||||
my_ipv4: Option<Ipv4Addr>,
|
||||
is_local_ipv6: impl Fn(Ipv6Addr) -> bool,
|
||||
) -> ChainType {
|
||||
if !is_in {
|
||||
return ChainType::Outbound;
|
||||
}
|
||||
|
||||
let is_local_dst = packet_info.dst_ip == my_ipv4.unwrap_or(Ipv4Addr::UNSPECIFIED)
|
||||
|| matches!(packet_info.dst_ip, IpAddr::V6(dst) if is_local_ipv6(dst));
|
||||
|
||||
if is_local_dst {
|
||||
ChainType::Inbound
|
||||
} else {
|
||||
ChainType::Forward
|
||||
}
|
||||
}
|
||||
|
||||
/// Common ACL processing logic
|
||||
pub fn process_packet_with_acl(
|
||||
&self,
|
||||
packet: &ZCPacket,
|
||||
is_in: bool,
|
||||
my_ipv4: Option<Ipv4Addr>,
|
||||
my_ipv6: Option<Ipv6Addr>,
|
||||
is_local_ipv6: impl Fn(Ipv6Addr) -> bool,
|
||||
route: &(dyn super::route_trait::Route + Send + Sync + 'static),
|
||||
) -> bool {
|
||||
if !self.acl_enabled.load(Ordering::Relaxed) {
|
||||
@@ -324,17 +345,7 @@ impl AclFilter {
|
||||
}
|
||||
};
|
||||
|
||||
let chain_type = if is_in {
|
||||
if packet_info.dst_ip == my_ipv4.unwrap_or(Ipv4Addr::UNSPECIFIED)
|
||||
|| packet_info.dst_ip == my_ipv6.unwrap_or(Ipv6Addr::UNSPECIFIED)
|
||||
{
|
||||
ChainType::Inbound
|
||||
} else {
|
||||
ChainType::Forward
|
||||
}
|
||||
} else {
|
||||
ChainType::Outbound
|
||||
};
|
||||
let chain_type = Self::classify_chain_type(is_in, &packet_info, my_ipv4, is_local_ipv6);
|
||||
|
||||
// Get current processor atomically
|
||||
let processor = self.get_processor();
|
||||
@@ -385,3 +396,92 @@ impl AclFilter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
common::acl_processor::PacketInfo,
|
||||
proto::acl::{Acl, ChainType, Protocol},
|
||||
};
|
||||
|
||||
use super::{AclFilter, OutboundAllowRecord};
|
||||
|
||||
fn packet_info(dst_ip: IpAddr) -> PacketInfo {
|
||||
PacketInfo {
|
||||
src_ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
|
||||
dst_ip,
|
||||
src_port: Some(1234),
|
||||
dst_port: Some(80),
|
||||
protocol: Protocol::Tcp,
|
||||
packet_size: 64,
|
||||
src_groups: Arc::new(Vec::new()),
|
||||
dst_groups: Arc::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_chain_type_treats_public_ipv6_lease_as_inbound() {
|
||||
let leased_ipv6 = Ipv6Addr::new(0x2001, 0xdb8, 0x100, 0, 0, 0, 0, 0x123);
|
||||
let packet_info = packet_info(IpAddr::V6(leased_ipv6));
|
||||
|
||||
let chain =
|
||||
AclFilter::classify_chain_type(true, &packet_info, None, |ip| ip == leased_ipv6);
|
||||
|
||||
assert_eq!(chain, ChainType::Inbound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_chain_type_keeps_non_local_ipv6_as_forward() {
|
||||
let leased_ipv6 = Ipv6Addr::new(0x2001, 0xdb8, 0x100, 0, 0, 0, 0, 0x123);
|
||||
let packet_info = packet_info(IpAddr::V6(Ipv6Addr::new(
|
||||
0x2001, 0xdb8, 0xffff, 2, 0, 0, 0, 0x100,
|
||||
)));
|
||||
|
||||
let chain =
|
||||
AclFilter::classify_chain_type(true, &packet_info, None, |ip| ip == leased_ipv6);
|
||||
|
||||
assert_eq!(chain, ChainType::Forward);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_rules_clears_outbound_allow_records() {
|
||||
let filter = AclFilter::new();
|
||||
filter.outbound_allow_records.insert(
|
||||
OutboundAllowRecord {
|
||||
src_ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
|
||||
dst_ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
|
||||
src_port: Some(1234),
|
||||
dst_port: Some(80),
|
||||
protocol: Protocol::Tcp,
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
assert_eq!(filter.outbound_allow_records.len(), 1);
|
||||
|
||||
filter.reload_rules(Some(&Acl::default()));
|
||||
|
||||
assert_eq!(filter.outbound_allow_records.len(), 0);
|
||||
|
||||
filter.outbound_allow_records.insert(
|
||||
OutboundAllowRecord {
|
||||
src_ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
|
||||
dst_ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
|
||||
src_port: Some(4321),
|
||||
dst_port: Some(443),
|
||||
protocol: Protocol::Tcp,
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
assert_eq!(filter.outbound_allow_records.len(), 1);
|
||||
|
||||
filter.reload_rules(None);
|
||||
|
||||
assert_eq!(filter.outbound_allow_records.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,17 @@ use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use crate::proto::peer_rpc::{TrustedCredentialPubkey, TrustedCredentialPubkeyProof};
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn current_unix_timestamp() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CredentialEntry {
|
||||
pubkey: String,
|
||||
@@ -20,10 +31,43 @@ struct CredentialEntry {
|
||||
groups: Vec<String>,
|
||||
allow_relay: bool,
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
reusable: bool,
|
||||
expiry_unix: i64,
|
||||
created_at_unix: i64,
|
||||
}
|
||||
|
||||
impl CredentialEntry {
|
||||
fn is_active_at(&self, now: i64) -> bool {
|
||||
self.expiry_unix > now
|
||||
}
|
||||
|
||||
fn to_trusted_credential(&self) -> Option<TrustedCredentialPubkey> {
|
||||
Some(TrustedCredentialPubkey {
|
||||
pubkey: CredentialManager::decode_pubkey_b64(&self.pubkey)?,
|
||||
groups: self.groups.clone(),
|
||||
allow_relay: self.allow_relay,
|
||||
expiry_unix: self.expiry_unix,
|
||||
allowed_proxy_cidrs: self.allowed_proxy_cidrs.clone(),
|
||||
reusable: Some(self.reusable),
|
||||
})
|
||||
}
|
||||
|
||||
fn to_api_credential_info(
|
||||
&self,
|
||||
credential_id: &str,
|
||||
) -> crate::proto::api::instance::CredentialInfo {
|
||||
crate::proto::api::instance::CredentialInfo {
|
||||
credential_id: credential_id.to_string(),
|
||||
groups: self.groups.clone(),
|
||||
allow_relay: self.allow_relay,
|
||||
expiry_unix: self.expiry_unix,
|
||||
allowed_proxy_cidrs: self.allowed_proxy_cidrs.clone(),
|
||||
reusable: Some(self.reusable),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CredentialManager {
|
||||
credentials: Mutex<HashMap<String, CredentialEntry>>,
|
||||
storage_path: Option<PathBuf>,
|
||||
@@ -46,7 +90,14 @@ impl CredentialManager {
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
ttl: Duration,
|
||||
) -> (String, String) {
|
||||
self.generate_credential_with_id(groups, allow_relay, allowed_proxy_cidrs, ttl, None)
|
||||
self.generate_credential_with_options(
|
||||
groups,
|
||||
allow_relay,
|
||||
allowed_proxy_cidrs,
|
||||
ttl,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn generate_credential_with_id(
|
||||
@@ -56,6 +107,25 @@ impl CredentialManager {
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
ttl: Duration,
|
||||
credential_id: Option<String>,
|
||||
) -> (String, String) {
|
||||
self.generate_credential_with_options(
|
||||
groups,
|
||||
allow_relay,
|
||||
allowed_proxy_cidrs,
|
||||
ttl,
|
||||
credential_id,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn generate_credential_with_options(
|
||||
&self,
|
||||
groups: Vec<String>,
|
||||
allow_relay: bool,
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
ttl: Duration,
|
||||
credential_id: Option<String>,
|
||||
reusable: bool,
|
||||
) -> (String, String) {
|
||||
let mut credentials = self.credentials.lock().unwrap();
|
||||
let id = if let Some(id) = credential_id
|
||||
@@ -72,7 +142,8 @@ impl CredentialManager {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
};
|
||||
|
||||
let (entry, secret) = Self::build_entry(groups, allow_relay, allowed_proxy_cidrs, ttl);
|
||||
let (entry, secret) =
|
||||
Self::build_entry(groups, allow_relay, allowed_proxy_cidrs, reusable, ttl);
|
||||
credentials.insert(id.clone(), entry);
|
||||
drop(credentials);
|
||||
self.save_to_disk();
|
||||
@@ -83,6 +154,7 @@ impl CredentialManager {
|
||||
groups: Vec<String>,
|
||||
allow_relay: bool,
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
reusable: bool,
|
||||
ttl: Duration,
|
||||
) -> (CredentialEntry, String) {
|
||||
let private = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
@@ -102,6 +174,7 @@ impl CredentialManager {
|
||||
groups,
|
||||
allow_relay,
|
||||
allowed_proxy_cidrs,
|
||||
reusable,
|
||||
expiry_unix,
|
||||
created_at_unix: now,
|
||||
};
|
||||
@@ -122,67 +195,41 @@ impl CredentialManager {
|
||||
}
|
||||
|
||||
pub fn get_trusted_pubkeys(&self, network_secret: &str) -> Vec<TrustedCredentialPubkeyProof> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let now = current_unix_timestamp();
|
||||
|
||||
self.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| e.expiry_unix > now)
|
||||
.map(|e| {
|
||||
let credential = TrustedCredentialPubkey {
|
||||
pubkey: Self::decode_pubkey_b64(&e.pubkey).unwrap_or_default(),
|
||||
groups: e.groups.clone(),
|
||||
allow_relay: e.allow_relay,
|
||||
expiry_unix: e.expiry_unix,
|
||||
allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(),
|
||||
};
|
||||
TrustedCredentialPubkeyProof::new_signed(credential, network_secret)
|
||||
})
|
||||
.filter(|e| {
|
||||
e.credential
|
||||
.as_ref()
|
||||
.map(|x| !x.pubkey.is_empty())
|
||||
.unwrap_or(false)
|
||||
.filter(|entry| entry.is_active_at(now))
|
||||
.filter_map(|entry| {
|
||||
entry.to_trusted_credential().map(|credential| {
|
||||
TrustedCredentialPubkeyProof::new_signed(credential, network_secret)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_pubkey_trusted(&self, pubkey: &[u8]) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let now = current_unix_timestamp();
|
||||
|
||||
let encoded = BASE64_STANDARD.encode(pubkey);
|
||||
self.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.any(|e| e.pubkey == encoded && e.expiry_unix > now)
|
||||
.any(|entry| entry.pubkey == encoded && entry.is_active_at(now))
|
||||
}
|
||||
|
||||
pub fn list_credentials(&self) -> Vec<crate::proto::api::instance::CredentialInfo> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let now = current_unix_timestamp();
|
||||
|
||||
self.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, e)| e.expiry_unix > now)
|
||||
.map(|(id, e)| crate::proto::api::instance::CredentialInfo {
|
||||
credential_id: id.clone(),
|
||||
groups: e.groups.clone(),
|
||||
allow_relay: e.allow_relay,
|
||||
expiry_unix: e.expiry_unix,
|
||||
allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(),
|
||||
})
|
||||
.filter(|(_, entry)| entry.is_active_at(now))
|
||||
.map(|(id, entry)| entry.to_api_credential_info(id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -254,6 +301,7 @@ mod tests {
|
||||
trusted[0].credential.as_ref().unwrap().groups,
|
||||
vec!["guest".to_string()]
|
||||
);
|
||||
assert_eq!(trusted[0].credential.as_ref().unwrap().reusable, Some(true));
|
||||
|
||||
assert!(mgr.revoke_credential(&id));
|
||||
assert!(!mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||
@@ -286,6 +334,7 @@ mod tests {
|
||||
|
||||
let list = mgr.list_credentials();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert!(list.iter().all(|item| item.reusable == Some(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -360,6 +409,7 @@ mod tests {
|
||||
trusted[0].credential.as_ref().unwrap().allowed_proxy_cidrs,
|
||||
vec!["10.0.0.0/8".to_string()]
|
||||
);
|
||||
assert_eq!(trusted[0].credential.as_ref().unwrap().reusable, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,6 +434,7 @@ mod tests {
|
||||
tc.credential.as_ref().unwrap().allowed_proxy_cidrs,
|
||||
vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()]
|
||||
);
|
||||
assert_eq!(tc.credential.as_ref().unwrap().reusable, Some(true));
|
||||
assert!(tc.credential.as_ref().unwrap().expiry_unix > 0);
|
||||
assert!(tc.verify_credential_hmac("sec"));
|
||||
assert!(
|
||||
@@ -431,6 +482,7 @@ mod tests {
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].groups, vec!["persist_group".to_string()]);
|
||||
assert!(list[0].allow_relay);
|
||||
assert_eq!(list[0].reusable, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,5 +525,62 @@ mod tests {
|
||||
assert_eq!(list[0].groups, vec!["group-a".to_string()]);
|
||||
assert!(!list[0].allow_relay);
|
||||
assert_eq!(list[0].allowed_proxy_cidrs, vec!["10.0.0.0/24".to_string()]);
|
||||
assert_eq!(list[0].reusable, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_non_reusable_credential() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
let (_id, secret) = mgr.generate_credential_with_options(
|
||||
vec!["single".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
Duration::from_secs(3600),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
let privkey_bytes: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap();
|
||||
let private = StaticSecret::from(privkey_bytes);
|
||||
let pubkey_bytes = PublicKey::from(&private).as_bytes().to_vec();
|
||||
|
||||
let listed = mgr.list_credentials();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].reusable, Some(false));
|
||||
assert!(mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||
|
||||
let trusted = mgr.get_trusted_pubkeys("sec");
|
||||
assert_eq!(trusted.len(), 1);
|
||||
assert_eq!(
|
||||
trusted[0].credential.as_ref().unwrap().reusable,
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_old_credentials_default_to_reusable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("legacy-creds.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"legacy-id": {
|
||||
"pubkey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"secret": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
|
||||
"groups": ["legacy"],
|
||||
"allow_relay": false,
|
||||
"allowed_proxy_cidrs": [],
|
||||
"expiry_unix": 4102444800,
|
||||
"created_at_unix": 1700000000
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mgr = CredentialManager::new(Some(path));
|
||||
let list = mgr.list_credentials();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].credential_id, "legacy-id");
|
||||
assert_eq!(list[0].reusable, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::{
|
||||
common::{PeerId, error::Error, global_ctx::ArcGlobalCtx, scoped_task::ScopedTask},
|
||||
common::{PeerId, error::Error, global_ctx::ArcGlobalCtx},
|
||||
tunnel::packet_def::ZCPacket,
|
||||
};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use super::{PacketRecvChan, peer_conn::PeerConn, peer_map::PeerMap, peer_rpc::PeerRpcManager};
|
||||
|
||||
@@ -13,7 +14,7 @@ pub struct ForeignNetworkClient {
|
||||
my_peer_id: PeerId,
|
||||
|
||||
peer_map: Arc<PeerMap>,
|
||||
task: Mutex<Option<ScopedTask<()>>>,
|
||||
task: Mutex<Option<AbortOnDropHandle<()>>>,
|
||||
}
|
||||
|
||||
impl ForeignNetworkClient {
|
||||
@@ -82,18 +83,15 @@ impl ForeignNetworkClient {
|
||||
|
||||
pub async fn run(&self) {
|
||||
let peer_map = Arc::downgrade(&self.peer_map);
|
||||
*self.task.lock().unwrap() = Some(
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
let Some(peer_map) = peer_map.upgrade() else {
|
||||
break;
|
||||
};
|
||||
peer_map.clean_peer_without_conn().await;
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
*self.task.lock().unwrap() = Some(AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
let Some(peer_map) = peer_map.upgrade() else {
|
||||
break;
|
||||
};
|
||||
peer_map.clean_peer_without_conn().await;
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
pub fn get_peer_map(&self) -> Arc<PeerMap> {
|
||||
|
||||
@@ -1575,6 +1575,41 @@ pub mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secure_center_can_serve_legacy_and_secure_foreign_networks() {
|
||||
let pm_center = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await;
|
||||
set_secure_mode_cfg(&pm_center.get_global_ctx(), true);
|
||||
|
||||
let legacy_a = create_mock_peer_manager_for_foreign_network("legacy-net").await;
|
||||
let legacy_b = create_mock_peer_manager_for_foreign_network("legacy-net").await;
|
||||
connect_peer_manager(legacy_a.clone(), pm_center.clone()).await;
|
||||
connect_peer_manager(legacy_b.clone(), pm_center.clone()).await;
|
||||
wait_route_appear(legacy_a.clone(), legacy_b.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let secure_a = create_mock_peer_manager_for_secure_foreign_network("secure-net").await;
|
||||
let secure_b = create_mock_peer_manager_for_secure_foreign_network("secure-net").await;
|
||||
connect_peer_manager(secure_a.clone(), pm_center.clone()).await;
|
||||
connect_peer_manager(secure_b.clone(), pm_center.clone()).await;
|
||||
wait_route_appear(secure_a.clone(), secure_b.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(2, legacy_a.list_routes().await.len());
|
||||
assert_eq!(2, legacy_b.list_routes().await.len());
|
||||
assert_eq!(2, secure_a.list_routes().await.len());
|
||||
assert_eq!(2, secure_b.list_routes().await.len());
|
||||
|
||||
let rpc_resp = pm_center
|
||||
.get_foreign_network_manager()
|
||||
.list_foreign_networks()
|
||||
.await;
|
||||
assert_eq!(2, rpc_resp.foreign_networks.len());
|
||||
assert_eq!(2, rpc_resp.foreign_networks["legacy-net"].peers.len());
|
||||
assert_eq!(2, rpc_resp.foreign_networks["secure-net"].peers.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_pubkey_trust_requires_ospf_credential_source() {
|
||||
let global_ctx = get_mock_global_ctx_with_network(Some(NetworkIdentity::new(
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod peer_ospf_route;
|
||||
pub mod peer_rpc;
|
||||
pub mod peer_rpc_service;
|
||||
pub mod peer_session;
|
||||
pub(crate) mod public_ipv6;
|
||||
pub mod relay_peer_map;
|
||||
pub mod route_trait;
|
||||
pub mod rpc_service;
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::{
|
||||
PacketRecvChan,
|
||||
peer_conn::{PeerConn, PeerConnId},
|
||||
};
|
||||
use crate::{common::shrink_dashmap, proto::api::instance::PeerConnInfo};
|
||||
use crate::{
|
||||
common::{
|
||||
PeerId,
|
||||
@@ -21,10 +22,7 @@ use crate::{
|
||||
proto::peer_rpc::PeerIdentityType,
|
||||
tunnel::packet_def::ZCPacket,
|
||||
};
|
||||
use crate::{
|
||||
common::{scoped_task::ScopedTask, shrink_dashmap},
|
||||
proto::api::instance::PeerConnInfo,
|
||||
};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
type ArcPeerConn = Arc<PeerConn>;
|
||||
type ConnMap = Arc<DashMap<PeerConnId, ArcPeerConn>>;
|
||||
@@ -37,14 +35,14 @@ pub struct Peer {
|
||||
packet_recv_chan: PacketRecvChan,
|
||||
|
||||
close_event_sender: mpsc::Sender<PeerConnId>,
|
||||
close_event_listener: ScopedTask<()>,
|
||||
close_event_listener: AbortOnDropHandle<()>,
|
||||
|
||||
shutdown_notifier: Arc<tokio::sync::Notify>,
|
||||
|
||||
default_conn_id: Arc<AtomicCell<PeerConnId>>,
|
||||
peer_identity_type: Arc<AtomicCell<Option<PeerIdentityType>>>,
|
||||
peer_public_key: Arc<RwLock<Option<Vec<u8>>>>,
|
||||
default_conn_id_clear_task: ScopedTask<()>,
|
||||
default_conn_id_clear_task: AbortOnDropHandle<()>,
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
@@ -64,7 +62,7 @@ impl Peer {
|
||||
let conns_copy = conns.clone();
|
||||
let shutdown_notifier_copy = shutdown_notifier.clone();
|
||||
let global_ctx_copy = global_ctx.clone();
|
||||
let close_event_listener = tokio::spawn(
|
||||
let close_event_listener = AbortOnDropHandle::new(tokio::spawn(
|
||||
async move {
|
||||
loop {
|
||||
select! {
|
||||
@@ -103,14 +101,13 @@ impl Peer {
|
||||
"peer_close_event_listener",
|
||||
?peer_node_id,
|
||||
)),
|
||||
)
|
||||
.into();
|
||||
));
|
||||
|
||||
let default_conn_id = Arc::new(AtomicCell::new(PeerConnId::default()));
|
||||
|
||||
let conns_copy = conns.clone();
|
||||
let default_conn_id_copy = default_conn_id.clone();
|
||||
let default_conn_id_clear_task = ScopedTask::from(tokio::spawn(async move {
|
||||
let default_conn_id_clear_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if conns_copy.len() > 1 {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use futures::{StreamExt, TryFutureExt};
|
||||
use std::{
|
||||
any::Any,
|
||||
fmt::Debug,
|
||||
@@ -8,11 +10,9 @@ use std::{
|
||||
},
|
||||
};
|
||||
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use futures::{StreamExt, TryFutureExt};
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use guarden::guard;
|
||||
use hmac::Mac;
|
||||
use prost::Message;
|
||||
|
||||
@@ -27,11 +27,17 @@ use zerocopy::AsBytes;
|
||||
|
||||
use snow::{HandshakeState, params::NoiseParams};
|
||||
|
||||
use super::{
|
||||
PacketRecvChan,
|
||||
peer_conn_ping::PeerConnPinger,
|
||||
peer_session::{PeerSession, PeerSessionAction},
|
||||
traffic_metrics::AggregateTrafficMetrics,
|
||||
};
|
||||
use crate::utils::BoxExt;
|
||||
use crate::{
|
||||
common::{
|
||||
PeerId,
|
||||
config::{NetworkIdentity, NetworkSecretDigest},
|
||||
defer,
|
||||
error::Error,
|
||||
global_ctx::ArcGlobalCtx,
|
||||
},
|
||||
@@ -54,13 +60,6 @@ use crate::{
|
||||
use_global_var,
|
||||
};
|
||||
|
||||
use super::{
|
||||
PacketRecvChan,
|
||||
peer_conn_ping::PeerConnPinger,
|
||||
peer_session::{PeerSession, PeerSessionAction},
|
||||
traffic_metrics::AggregateTrafficMetrics,
|
||||
};
|
||||
|
||||
pub type PeerConnId = uuid::Uuid;
|
||||
|
||||
const MAGIC: u32 = 0xd1e1a5e1;
|
||||
@@ -381,9 +380,9 @@ impl PeerConn {
|
||||
session_filter,
|
||||
noise_handshake_result: None,
|
||||
|
||||
tunnel: Arc::new(Mutex::new(Box::new(defer::Defer::new(move || {
|
||||
mpsc_tunnel.close()
|
||||
})))),
|
||||
tunnel: Arc::new(Mutex::new(
|
||||
guard!([mut mpsc_tunnel] mpsc_tunnel.close()).boxed(),
|
||||
)),
|
||||
sink,
|
||||
recv: Mutex::new(Some(recv)),
|
||||
tunnel_info,
|
||||
@@ -1606,7 +1605,6 @@ pub mod tests {
|
||||
use crate::common::global_ctx::GlobalCtx;
|
||||
use crate::common::global_ctx::tests::get_mock_global_ctx;
|
||||
use crate::common::new_peer_id;
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use crate::common::stats_manager::{LabelSet, LabelType, MetricName};
|
||||
use crate::peers::create_packet_recv_chan;
|
||||
use crate::peers::recv_packet_from_chan;
|
||||
@@ -1614,6 +1612,7 @@ pub mod tests {
|
||||
use crate::tunnel::filter::PacketRecorderTunnelFilter;
|
||||
use crate::tunnel::filter::tests::DropSendTunnelFilter;
|
||||
use crate::tunnel::ring::create_ring_tunnel_pair;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
pub fn set_secure_mode_cfg(global_ctx: &GlobalCtx, enabled: bool) {
|
||||
if !enabled {
|
||||
@@ -2200,7 +2199,7 @@ pub mod tests {
|
||||
c_peer.start_recv_loop(create_packet_recv_chan().0).await;
|
||||
|
||||
let throughput = c_peer.throughput.clone();
|
||||
let _t = ScopedTask::from(tokio::spawn(async move {
|
||||
let _t = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
// if not drop both, we mock some rx traffic for client peer to test pinger
|
||||
if drop_both {
|
||||
return;
|
||||
|
||||
@@ -536,6 +536,21 @@ impl PeerManager {
|
||||
async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
||||
let my_identity = self.global_ctx.get_network_identity();
|
||||
let peer_identity = peer_conn.get_network_identity();
|
||||
let conn_info = peer_conn.get_conn_info();
|
||||
let local_secure_mode = self
|
||||
.global_ctx
|
||||
.config
|
||||
.get_secure_mode()
|
||||
.as_ref()
|
||||
.map(|cfg| cfg.enabled)
|
||||
.unwrap_or(false);
|
||||
let peer_secure_mode = !conn_info.noise_remote_static_pubkey.is_empty();
|
||||
|
||||
if local_secure_mode != peer_secure_mode {
|
||||
return Err(Error::SecretKeyError(
|
||||
"same-network peers must use the same secure mode".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// For credential nodes, network_secret_digest is either None or all-zeros
|
||||
// (all-zeros when received over the wire via handshake).
|
||||
@@ -1047,7 +1062,7 @@ impl PeerManager {
|
||||
&ret,
|
||||
true,
|
||||
global_ctx.get_ipv4().map(|x| x.address()),
|
||||
global_ctx.get_ipv6().map(|x| x.address()),
|
||||
|dst| global_ctx.is_ip_local_ipv6(&dst),
|
||||
&route,
|
||||
) {
|
||||
continue;
|
||||
@@ -1276,6 +1291,18 @@ impl PeerManager {
|
||||
self.get_route().list_proxy_cidrs_v6().await
|
||||
}
|
||||
|
||||
pub async fn list_public_ipv6_routes(&self) -> BTreeSet<cidr::Ipv6Inet> {
|
||||
self.get_route().list_public_ipv6_routes().await
|
||||
}
|
||||
|
||||
pub async fn get_my_public_ipv6_addr(&self) -> Option<cidr::Ipv6Inet> {
|
||||
self.get_route().get_my_public_ipv6_addr().await
|
||||
}
|
||||
|
||||
pub async fn get_local_public_ipv6_info(&self) -> instance::ListPublicIpv6InfoResponse {
|
||||
self.get_route().get_local_public_ipv6_info().await
|
||||
}
|
||||
|
||||
pub async fn dump_route(&self) -> String {
|
||||
self.get_route().dump().await
|
||||
}
|
||||
@@ -1315,7 +1342,7 @@ impl PeerManager {
|
||||
data,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
|_| false,
|
||||
&self.get_route(),
|
||||
) {
|
||||
return false;
|
||||
@@ -1517,6 +1544,10 @@ impl PeerManager {
|
||||
dst_peers.extend(self.peers.list_routes().await.iter().map(|x| *x.key()));
|
||||
} else if let Some(peer_id) = self.peers.get_peer_id_by_ipv6(ipv6_addr).await {
|
||||
dst_peers.push(peer_id);
|
||||
} else if !ipv6_addr.is_unicast_link_local()
|
||||
&& let Some(peer_id) = self.get_route().get_public_ipv6_gateway_peer_id().await
|
||||
{
|
||||
dst_peers.push(peer_id);
|
||||
} else if !ipv6_addr.is_unicast_link_local() {
|
||||
// NOTE: never route link local address to exit node.
|
||||
for exit_node in self.exit_nodes.read().await.iter() {
|
||||
@@ -1642,8 +1673,12 @@ impl PeerManager {
|
||||
|
||||
#[cfg(not(target_env = "ohos"))]
|
||||
{
|
||||
if not_send_to_self && *peer_id == self.my_peer_id {
|
||||
// the packet may be sent to vpn portal, so we just set flags instead of drop it
|
||||
if not_send_to_self
|
||||
&& *peer_id == self.my_peer_id
|
||||
&& !self.global_ctx.is_ip_local_virtual_ip(&ip_addr)
|
||||
{
|
||||
// Keep the loop-prevention flags for proxy-induced self-delivery where
|
||||
// the destination is not this node's own EasyTier-managed IP.
|
||||
hdr.set_not_send_to_tun(true);
|
||||
hdr.set_no_proxy(true);
|
||||
}
|
||||
@@ -1860,6 +1895,15 @@ impl PeerManager {
|
||||
version: EASYTIER_VERSION.to_string(),
|
||||
feature_flag: Some(self.global_ctx.get_feature_flags()),
|
||||
ip_list: Some(self.global_ctx.get_ip_collector().collect_ip_addrs().await),
|
||||
public_ipv6_addr: self.get_my_public_ipv6_addr().await.map(Into::into),
|
||||
ipv6_public_addr_prefix: self
|
||||
.global_ctx
|
||||
.get_advertised_ipv6_public_addr_prefix()
|
||||
.map(|prefix| {
|
||||
cidr::Ipv6Inet::new(prefix.first_address(), prefix.network_length())
|
||||
.unwrap()
|
||||
.into()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2717,7 +2761,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peer_manager_safe_server_accept_legacy_client() {
|
||||
async fn peer_manager_same_network_secure_mode_mismatch_rejected() {
|
||||
let peer_mgr_client = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await;
|
||||
let peer_mgr_server = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await;
|
||||
|
||||
@@ -2737,64 +2781,65 @@ mod tests {
|
||||
peer_mgr_client.add_client_tunnel(c_ring, false),
|
||||
peer_mgr_server.add_tunnel_as_server(s_ring, true)
|
||||
);
|
||||
let (server_id, _) = c_ret.unwrap();
|
||||
s_ret.unwrap();
|
||||
let _ = c_ret;
|
||||
assert!(
|
||||
s_ret.is_err(),
|
||||
"same-network peer with mismatched secure mode should be rejected"
|
||||
);
|
||||
|
||||
wait_for_condition(
|
||||
|| {
|
||||
let peer_mgr_client = peer_mgr_client.clone();
|
||||
async move {
|
||||
if !peer_mgr_client
|
||||
.get_peer_map()
|
||||
.list_peers_with_conn()
|
||||
.await
|
||||
.contains(&server_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(conns) = peer_mgr_client
|
||||
.get_peer_map()
|
||||
.list_peer_conns(server_id)
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
conns.iter().any(|c| {
|
||||
c.noise_local_static_pubkey.is_empty()
|
||||
&& c.noise_remote_static_pubkey.is_empty()
|
||||
&& c.secure_auth_level == SecureAuthLevel::None as i32
|
||||
})
|
||||
}
|
||||
},
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
|
||||
let client_id = peer_mgr_client.my_peer_id();
|
||||
wait_for_condition(
|
||||
|| {
|
||||
let peer_mgr_server = peer_mgr_server.clone();
|
||||
async move {
|
||||
if !peer_mgr_server
|
||||
peer_mgr_server
|
||||
.get_peer_map()
|
||||
.list_peers_with_conn()
|
||||
.await
|
||||
.contains(&client_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(conns) = peer_mgr_server
|
||||
.is_empty()
|
||||
}
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_node_rejects_legacy_client() {
|
||||
let peer_mgr_client = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await;
|
||||
let peer_mgr_server = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await;
|
||||
|
||||
peer_mgr_client
|
||||
.get_global_ctx()
|
||||
.config
|
||||
.set_network_identity(NetworkIdentity::new("net1".to_string(), "sec1".to_string()));
|
||||
peer_mgr_server
|
||||
.get_global_ctx()
|
||||
.config
|
||||
.set_network_identity(NetworkIdentity::new_credential("net1".to_string()));
|
||||
|
||||
set_secure_mode_cfg(&peer_mgr_server.get_global_ctx(), true);
|
||||
|
||||
let (c_ring, s_ring) = create_ring_tunnel_pair();
|
||||
let (c_ret, s_ret) = tokio::join!(
|
||||
peer_mgr_client.add_client_tunnel(c_ring, false),
|
||||
peer_mgr_server.add_tunnel_as_server(s_ring, true)
|
||||
);
|
||||
|
||||
let _ = c_ret;
|
||||
assert!(
|
||||
s_ret.is_err(),
|
||||
"credential server should reject legacy client"
|
||||
);
|
||||
|
||||
wait_for_condition(
|
||||
|| {
|
||||
let peer_mgr_server = peer_mgr_server.clone();
|
||||
async move {
|
||||
peer_mgr_server
|
||||
.get_peer_map()
|
||||
.list_peer_conns(client_id)
|
||||
.list_peers_with_conn()
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
conns.iter().any(|c| {
|
||||
c.noise_local_static_pubkey.is_empty()
|
||||
&& c.noise_remote_static_pubkey.is_empty()
|
||||
&& c.secure_auth_level == SecureAuthLevel::None as i32
|
||||
})
|
||||
.is_empty()
|
||||
}
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,29 @@ use crate::{
|
||||
proto::{
|
||||
common::Void,
|
||||
peer_rpc::{
|
||||
DirectConnectorRpc, GetIpListRequest, GetIpListResponse, SendV6HolePunchPacketRequest,
|
||||
DirectConnectorRpc, GetIpListRequest, GetIpListResponse, SendUdpHolePunchPacketRequest,
|
||||
},
|
||||
rpc_types::{self, controller::BaseController},
|
||||
},
|
||||
tunnel::udp,
|
||||
};
|
||||
|
||||
fn remove_easytier_managed_ipv6s(ret: &mut GetIpListResponse, global_ctx: &ArcGlobalCtx) {
|
||||
ret.interface_ipv6s.retain(|ip| {
|
||||
let ip = std::net::Ipv6Addr::from(*ip);
|
||||
!global_ctx.is_ip_easytier_managed_ipv6(&ip)
|
||||
});
|
||||
|
||||
if ret
|
||||
.public_ipv6
|
||||
.as_ref()
|
||||
.map(|ip| std::net::Ipv6Addr::from(*ip))
|
||||
.is_some_and(|ip| global_ctx.is_ip_easytier_managed_ipv6(&ip))
|
||||
{
|
||||
ret.public_ipv6 = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DirectConnectorManagerRpcServer {
|
||||
// TODO: this only cache for one src peer, should make it global
|
||||
@@ -36,11 +52,7 @@ impl DirectConnectorRpc for DirectConnectorManagerRpcServer {
|
||||
.chain(self.global_ctx.get_running_listeners())
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
// remove et ipv6 from the interface ipv6 list
|
||||
if let Some(et_ipv6) = self.global_ctx.get_ipv6() {
|
||||
let et_ipv6: crate::proto::common::Ipv6Addr = et_ipv6.address().into();
|
||||
ret.interface_ipv6s.retain(|x| *x != et_ipv6);
|
||||
}
|
||||
remove_easytier_managed_ipv6s(&mut ret, &self.global_ctx);
|
||||
tracing::trace!(
|
||||
"get_ip_list: public_ipv4: {:?}, public_ipv6: {:?}, listeners: {:?}",
|
||||
ret.public_ipv4,
|
||||
@@ -50,29 +62,29 @@ impl DirectConnectorRpc for DirectConnectorManagerRpcServer {
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
async fn send_v6_hole_punch_packet(
|
||||
async fn send_udp_hole_punch_packet(
|
||||
&self,
|
||||
_: BaseController,
|
||||
req: SendV6HolePunchPacketRequest,
|
||||
req: SendUdpHolePunchPacketRequest,
|
||||
) -> rpc_types::error::Result<Void> {
|
||||
let listener_port = req.listener_port as u16;
|
||||
let SocketAddr::V6(connector_addr) = req
|
||||
let connector_addr: SocketAddr = req
|
||||
.connector_addr
|
||||
.ok_or(anyhow::anyhow!("connector_addr is required"))?
|
||||
.into()
|
||||
else {
|
||||
return Err(anyhow::anyhow!("connector_addr is not a v6 address").into());
|
||||
};
|
||||
.into();
|
||||
|
||||
tracing::info!(
|
||||
"Sending v6 hole punch packet to {} from listener port {}",
|
||||
"Sending udp hole punch packet to {} from listener port {}",
|
||||
connector_addr,
|
||||
listener_port
|
||||
);
|
||||
|
||||
// send 3 packets to the connector
|
||||
for _ in 0..3 {
|
||||
udp::send_v6_hole_punch_packet(listener_port, connector_addr).await?;
|
||||
match connector_addr {
|
||||
SocketAddr::V4(addr) => udp::send_v4_hole_punch_packet(listener_port, addr).await?,
|
||||
SocketAddr::V6(addr) => udp::send_v6_hole_punch_packet(listener_port, addr).await?,
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
}
|
||||
Ok(Default::default())
|
||||
@@ -84,3 +96,41 @@ impl DirectConnectorManagerRpcServer {
|
||||
Self { global_ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{
|
||||
common::global_ctx::tests::get_mock_global_ctx,
|
||||
peers::peer_rpc_service::remove_easytier_managed_ipv6s, proto::peer_rpc::GetIpListResponse,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_ip_list_sanitizer_removes_managed_ipv6_from_all_sources() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
let virtual_ipv6 = "fd00::1/64".parse().unwrap();
|
||||
let public_ipv6 = "2001:db8::2/128".parse().unwrap();
|
||||
let physical_ipv6: std::net::Ipv6Addr = "2001:db8::3".parse().unwrap();
|
||||
let routed_ipv6: cidr::Ipv6Inet = "2001:db8::4/128".parse().unwrap();
|
||||
global_ctx.set_ipv6(Some(virtual_ipv6));
|
||||
global_ctx.set_public_ipv6_lease(Some(public_ipv6));
|
||||
global_ctx.set_public_ipv6_routes(BTreeSet::from([routed_ipv6]));
|
||||
|
||||
let mut ip_list = GetIpListResponse {
|
||||
public_ipv6: Some(public_ipv6.address().into()),
|
||||
interface_ipv6s: vec![
|
||||
virtual_ipv6.address().into(),
|
||||
public_ipv6.address().into(),
|
||||
routed_ipv6.address().into(),
|
||||
physical_ipv6.into(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
remove_easytier_managed_ipv6s(&mut ip_list, &global_ctx);
|
||||
|
||||
assert_eq!(ip_list.public_ipv6, None);
|
||||
assert_eq!(ip_list.interface_ipv6s, vec![physical_ipv6.into()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ use tokio::select;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use anyhow::Error;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use super::peer_manager::PeerManager;
|
||||
|
||||
@@ -72,7 +72,7 @@ pub trait PeerTaskLauncher: Send + Sync + Clone + 'static {
|
||||
|
||||
pub struct PeerTaskManager<Launcher: PeerTaskLauncher> {
|
||||
launcher: Launcher,
|
||||
main_loop_task: Mutex<Option<ScopedTask<()>>>,
|
||||
main_loop_task: Mutex<Option<AbortOnDropHandle<()>>>,
|
||||
run_signal: Arc<Notify>,
|
||||
external_signal: Option<Arc<ExternalTaskSignal>>,
|
||||
data: Launcher::Data,
|
||||
@@ -105,13 +105,12 @@ where
|
||||
}
|
||||
|
||||
pub fn start(&self) {
|
||||
let task = tokio::spawn(Self::main_loop(
|
||||
let task = AbortOnDropHandle::new(tokio::spawn(Self::main_loop(
|
||||
self.launcher.clone(),
|
||||
self.data.clone(),
|
||||
self.run_signal.clone(),
|
||||
self.external_signal.clone(),
|
||||
))
|
||||
.into();
|
||||
)));
|
||||
self.main_loop_task.lock().unwrap().replace(task);
|
||||
}
|
||||
|
||||
@@ -121,7 +120,7 @@ where
|
||||
signal: Arc<Notify>,
|
||||
external_signal: Option<Arc<ExternalTaskSignal>>,
|
||||
) {
|
||||
let peer_task_map = Arc::new(DashMap::<C, ScopedTask<Result<T, Error>>>::new());
|
||||
let peer_task_map = Arc::new(DashMap::<C, AbortOnDropHandle<Result<T, Error>>>::new());
|
||||
let mut external_signal_version = external_signal.as_ref().map(|signal| signal.version());
|
||||
|
||||
loop {
|
||||
@@ -158,8 +157,10 @@ where
|
||||
}
|
||||
|
||||
tracing::debug!(?item, "launch hole punching task");
|
||||
peer_task_map
|
||||
.insert(item.clone(), launcher.launch_task(&data, item).await.into());
|
||||
peer_task_map.insert(
|
||||
item.clone(),
|
||||
AbortOnDropHandle::new(launcher.launch_task(&data, item).await),
|
||||
);
|
||||
}
|
||||
} else if peer_task_map.is_empty() {
|
||||
launcher.all_task_done(&data).await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
use cidr::Ipv6Inet;
|
||||
use cidr::{Ipv4Cidr, Ipv6Cidr};
|
||||
use dashmap::DashMap;
|
||||
use std::{
|
||||
@@ -8,9 +9,12 @@ use std::{
|
||||
|
||||
use crate::{
|
||||
common::{PeerId, global_ctx::NetworkIdentity},
|
||||
proto::peer_rpc::{
|
||||
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, PeerIdentityType,
|
||||
RouteForeignNetworkInfos, RouteForeignNetworkSummary, RoutePeerInfo,
|
||||
proto::{
|
||||
api::instance::ListPublicIpv6InfoResponse,
|
||||
peer_rpc::{
|
||||
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, PeerIdentityType,
|
||||
RouteForeignNetworkInfos, RouteForeignNetworkSummary, RoutePeerInfo,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -93,6 +97,22 @@ pub trait Route {
|
||||
// TODO: rewrite route management, remove this
|
||||
async fn list_proxy_cidrs_v6(&self) -> BTreeSet<Ipv6Cidr>;
|
||||
|
||||
async fn list_public_ipv6_routes(&self) -> BTreeSet<Ipv6Inet> {
|
||||
BTreeSet::new()
|
||||
}
|
||||
|
||||
async fn get_my_public_ipv6_addr(&self) -> Option<Ipv6Inet> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_public_ipv6_gateway_peer_id(&self) -> Option<PeerId> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_local_public_ipv6_info(&self) -> ListPublicIpv6InfoResponse {
|
||||
ListPublicIpv6InfoResponse::default()
|
||||
}
|
||||
|
||||
async fn get_peer_id_by_ipv4(&self, _ipv4: &Ipv4Addr) -> Option<PeerId> {
|
||||
None
|
||||
}
|
||||
@@ -194,6 +214,14 @@ impl Route for MockRoute {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_public_ipv6_routes(&self) -> BTreeSet<Ipv6Inet> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_my_public_ipv6_addr(&self) -> Option<Ipv6Inet> {
|
||||
panic!("mock route")
|
||||
}
|
||||
|
||||
async fn get_peer_info(&self, _peer_id: PeerId) -> Option<RoutePeerInfo> {
|
||||
panic!("mock route")
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ use crate::{
|
||||
GetWhitelistRequest, GetWhitelistResponse, ListCredentialsRequest,
|
||||
ListCredentialsResponse, ListForeignNetworkRequest, ListForeignNetworkResponse,
|
||||
ListGlobalForeignNetworkRequest, ListGlobalForeignNetworkResponse, ListPeerRequest,
|
||||
ListPeerResponse, ListRouteRequest, ListRouteResponse, PeerInfo, PeerManageRpc,
|
||||
RevokeCredentialRequest, RevokeCredentialResponse, ShowNodeInfoRequest,
|
||||
ShowNodeInfoResponse,
|
||||
ListPeerResponse, ListPublicIpv6InfoRequest, ListPublicIpv6InfoResponse,
|
||||
ListRouteRequest, ListRouteResponse, PeerInfo, PeerManageRpc, RevokeCredentialRequest,
|
||||
RevokeCredentialResponse, ShowNodeInfoRequest, ShowNodeInfoResponse,
|
||||
},
|
||||
rpc_types::{self, controller::BaseController},
|
||||
},
|
||||
@@ -99,6 +99,16 @@ impl PeerManageRpc for PeerManagerRpcService {
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
async fn list_public_ipv6_info(
|
||||
&self,
|
||||
_: BaseController,
|
||||
_request: ListPublicIpv6InfoRequest,
|
||||
) -> Result<ListPublicIpv6InfoResponse, rpc_types::error::Error> {
|
||||
Ok(weak_upgrade(&self.peer_manager)?
|
||||
.get_local_public_ipv6_info()
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn list_route(
|
||||
&self,
|
||||
_: BaseController,
|
||||
@@ -234,12 +244,13 @@ impl CredentialManageRpc for PeerManagerRpcService {
|
||||
|
||||
let (id, secret) = global_ctx
|
||||
.get_credential_manager()
|
||||
.generate_credential_with_id(
|
||||
.generate_credential_with_options(
|
||||
request.groups,
|
||||
request.allow_relay,
|
||||
request.allowed_proxy_cidrs,
|
||||
ttl,
|
||||
request.credential_id,
|
||||
request.reusable.unwrap_or(true),
|
||||
);
|
||||
|
||||
global_ctx.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
|
||||
|
||||
@@ -24,6 +24,9 @@ message InstanceConfigPatch {
|
||||
repeated ExitNodePatch exit_nodes = 8;
|
||||
repeated UrlPatch mapped_listeners = 9;
|
||||
repeated UrlPatch connectors = 10;
|
||||
optional bool ipv6_public_addr_provider = 11;
|
||||
optional bool ipv6_public_addr_auto = 12;
|
||||
optional string ipv6_public_addr_prefix = 13;
|
||||
}
|
||||
|
||||
message PortForwardPatch {
|
||||
|
||||
@@ -81,6 +81,8 @@ message Route {
|
||||
optional int32 path_latency_latency_first = 14;
|
||||
|
||||
common.Ipv6Inet ipv6_addr = 15;
|
||||
common.Ipv6Inet public_ipv6_addr = 16;
|
||||
common.Ipv6Inet ipv6_public_addr_prefix = 17;
|
||||
}
|
||||
|
||||
message PeerRoutePair {
|
||||
@@ -100,12 +102,29 @@ message NodeInfo {
|
||||
string version = 9;
|
||||
common.PeerFeatureFlag feature_flag = 10;
|
||||
peer_rpc.GetIpListResponse ip_list = 11;
|
||||
common.Ipv6Inet public_ipv6_addr = 12;
|
||||
common.Ipv6Inet ipv6_public_addr_prefix = 13;
|
||||
}
|
||||
|
||||
message ShowNodeInfoRequest { InstanceIdentifier instance = 1; }
|
||||
|
||||
message ShowNodeInfoResponse { NodeInfo node_info = 1; }
|
||||
|
||||
message PublicIpv6LeaseInfo {
|
||||
uint32 peer_id = 1;
|
||||
string inst_id = 2;
|
||||
common.Ipv6Inet leased_addr = 3;
|
||||
int64 valid_until_unix_seconds = 4;
|
||||
bool reused = 5;
|
||||
}
|
||||
|
||||
message ListPublicIpv6InfoRequest { InstanceIdentifier instance = 1; }
|
||||
|
||||
message ListPublicIpv6InfoResponse {
|
||||
common.Ipv6Inet provider_prefix = 1;
|
||||
repeated PublicIpv6LeaseInfo provider_leases = 2;
|
||||
}
|
||||
|
||||
message ListRouteRequest { InstanceIdentifier instance = 1; }
|
||||
|
||||
message ListRouteResponse { repeated Route routes = 1; }
|
||||
@@ -167,6 +186,8 @@ message GetForeignNetworkSummaryResponse {
|
||||
|
||||
service PeerManageRpc {
|
||||
rpc ListPeer(ListPeerRequest) returns (ListPeerResponse);
|
||||
rpc ListPublicIpv6Info(ListPublicIpv6InfoRequest)
|
||||
returns (ListPublicIpv6InfoResponse);
|
||||
rpc ListRoute(ListRouteRequest) returns (ListRouteResponse);
|
||||
rpc DumpRoute(DumpRouteRequest) returns (DumpRouteResponse);
|
||||
rpc ListForeignNetwork(ListForeignNetworkRequest)
|
||||
@@ -318,6 +339,7 @@ message GenerateCredentialRequest {
|
||||
int64 ttl_seconds = 4; // must be > 0: credential TTL in seconds (0 / omitted is invalid)
|
||||
optional string credential_id = 5; // optional: user-specified credential id, reused if already exists
|
||||
InstanceIdentifier instance = 6; // target network instance
|
||||
optional bool reusable = 7; // default true: allow multiple peers to reuse this credential
|
||||
}
|
||||
|
||||
message GenerateCredentialResponse {
|
||||
@@ -344,6 +366,7 @@ message CredentialInfo {
|
||||
bool allow_relay = 3;
|
||||
int64 expiry_unix = 4;
|
||||
repeated string allowed_proxy_cidrs = 5;
|
||||
optional bool reusable = 6;
|
||||
}
|
||||
|
||||
message ListCredentialsResponse {
|
||||
|
||||
@@ -13,6 +13,12 @@ enum NetworkingMethod {
|
||||
Standalone = 2;
|
||||
}
|
||||
|
||||
enum ConfigSource {
|
||||
ConfigSourceUnspecified = 0;
|
||||
ConfigSourceUser = 1;
|
||||
ConfigSourceWebhook = 2;
|
||||
}
|
||||
|
||||
message NetworkConfig {
|
||||
optional string instance_id = 1;
|
||||
|
||||
@@ -89,6 +95,10 @@ message NetworkConfig {
|
||||
optional bool lazy_p2p = 58;
|
||||
optional bool need_p2p = 59;
|
||||
optional uint64 instance_recv_bps_limit = 60;
|
||||
optional bool disable_upnp = 61;
|
||||
optional bool ipv6_public_addr_provider = 62;
|
||||
optional bool ipv6_public_addr_auto = 63;
|
||||
optional string ipv6_public_addr_prefix = 64;
|
||||
}
|
||||
|
||||
message PortForwardConfig {
|
||||
@@ -131,6 +141,7 @@ message NetworkMeta {
|
||||
string network_name = 2;
|
||||
uint32 config_permission = 3;
|
||||
string instance_name = 4;
|
||||
ConfigSource source = 5;
|
||||
}
|
||||
|
||||
message ValidateConfigRequest { NetworkConfig config = 1; }
|
||||
@@ -141,6 +152,7 @@ message RunNetworkInstanceRequest {
|
||||
common.UUID inst_id = 1;
|
||||
NetworkConfig config = 2;
|
||||
bool overwrite = 3;
|
||||
ConfigSource source = 4;
|
||||
}
|
||||
|
||||
message RunNetworkInstanceResponse { common.UUID inst_id = 1; }
|
||||
@@ -167,7 +179,10 @@ message DeleteNetworkInstanceResponse {
|
||||
|
||||
message GetNetworkInstanceConfigRequest { common.UUID inst_id = 1; }
|
||||
|
||||
message GetNetworkInstanceConfigResponse { NetworkConfig config = 1; }
|
||||
message GetNetworkInstanceConfigResponse {
|
||||
NetworkConfig config = 1;
|
||||
ConfigSource source = 2;
|
||||
}
|
||||
|
||||
message ListNetworkInstanceMetaRequest { repeated common.UUID inst_ids = 1; }
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ message FlagsInConfig {
|
||||
bool lazy_p2p = 37;
|
||||
bool need_p2p = 38;
|
||||
uint64 instance_recv_bps_limit = 39;
|
||||
bool disable_upnp = 40;
|
||||
}
|
||||
|
||||
message RpcDescriptor {
|
||||
@@ -224,6 +225,7 @@ message PeerFeatureFlag {
|
||||
bool is_credential_peer = 8;
|
||||
bool need_p2p = 9;
|
||||
bool disable_p2p = 10;
|
||||
bool ipv6_public_addr_provider = 11;
|
||||
}
|
||||
|
||||
enum SocketType {
|
||||
|
||||
@@ -11,6 +11,7 @@ message TrustedCredentialPubkey {
|
||||
bool allow_relay = 3; // whether this credential node can relay data
|
||||
int64 expiry_unix = 4; // expiry time (Unix timestamp)
|
||||
repeated string allowed_proxy_cidrs = 5; // allowed proxy_cidrs ranges
|
||||
optional bool reusable = 6; // whether multiple peers may use the same credential concurrently
|
||||
}
|
||||
|
||||
message TrustedCredentialPubkeyProof {
|
||||
@@ -46,6 +47,9 @@ message RoutePeerInfo {
|
||||
|
||||
// Trusted credential public keys published by admin nodes (holding network_secret)
|
||||
repeated TrustedCredentialPubkeyProof trusted_credential_pubkeys = 19;
|
||||
|
||||
optional common.Ipv6Inet ipv6_public_addr_prefix = 22;
|
||||
optional common.Ipv6Inet ipv6_public_addr_lease = 24;
|
||||
}
|
||||
|
||||
message PeerIdVersion {
|
||||
@@ -132,6 +136,46 @@ service OspfRouteRpc {
|
||||
rpc SyncRouteInfo(SyncRouteInfoRequest) returns (SyncRouteInfoResponse);
|
||||
}
|
||||
|
||||
message AcquireIpv6PublicAddrLeaseRequest {
|
||||
uint32 peer_id = 1;
|
||||
common.UUID inst_id = 2;
|
||||
}
|
||||
|
||||
message RenewIpv6PublicAddrLeaseRequest {
|
||||
uint32 peer_id = 1;
|
||||
common.UUID inst_id = 2;
|
||||
common.Ipv6Inet leased_addr = 3;
|
||||
}
|
||||
|
||||
message ReleaseIpv6PublicAddrLeaseRequest {
|
||||
uint32 peer_id = 1;
|
||||
common.UUID inst_id = 2;
|
||||
}
|
||||
|
||||
message GetIpv6PublicAddrLeaseRequest {
|
||||
uint32 peer_id = 1;
|
||||
common.UUID inst_id = 2;
|
||||
}
|
||||
|
||||
message Ipv6PublicAddrLeaseReply {
|
||||
uint32 provider_peer_id = 1;
|
||||
common.UUID provider_inst_id = 2;
|
||||
common.Ipv6Inet provider_prefix = 3;
|
||||
common.Ipv6Inet leased_addr = 4;
|
||||
google.protobuf.Timestamp valid_until = 5;
|
||||
bool reused = 6;
|
||||
optional string error_msg = 7;
|
||||
}
|
||||
|
||||
service PublicIpv6AddrRpc {
|
||||
rpc AcquireLease(AcquireIpv6PublicAddrLeaseRequest)
|
||||
returns (Ipv6PublicAddrLeaseReply);
|
||||
rpc RenewLease(RenewIpv6PublicAddrLeaseRequest)
|
||||
returns (Ipv6PublicAddrLeaseReply);
|
||||
rpc ReleaseLease(ReleaseIpv6PublicAddrLeaseRequest) returns (common.Void);
|
||||
rpc GetLease(GetIpv6PublicAddrLeaseRequest) returns (Ipv6PublicAddrLeaseReply);
|
||||
}
|
||||
|
||||
message GetIpListRequest {}
|
||||
|
||||
message GetIpListResponse {
|
||||
@@ -142,18 +186,19 @@ message GetIpListResponse {
|
||||
repeated common.Url listeners = 5;
|
||||
}
|
||||
|
||||
message SendV6HolePunchPacketRequest {
|
||||
message SendUdpHolePunchPacketRequest {
|
||||
common.SocketAddr connector_addr = 1;
|
||||
uint32 listener_port = 2;
|
||||
}
|
||||
|
||||
service DirectConnectorRpc {
|
||||
rpc GetIpList(GetIpListRequest) returns (GetIpListResponse);
|
||||
rpc SendV6HolePunchPacket(SendV6HolePunchPacketRequest) returns (common.Void);
|
||||
rpc SendUdpHolePunchPacket(SendUdpHolePunchPacketRequest) returns (common.Void);
|
||||
}
|
||||
|
||||
message SelectPunchListenerRequest {
|
||||
bool force_new = 1;
|
||||
bool prefer_port_mapping = 2;
|
||||
}
|
||||
|
||||
message SelectPunchListenerResponse {
|
||||
|
||||
@@ -40,17 +40,24 @@ impl PeerGroupInfo {
|
||||
}
|
||||
|
||||
impl TrustedCredentialPubkeyProof {
|
||||
pub fn generate_credential_hmac(
|
||||
credential: &TrustedCredentialPubkey,
|
||||
pub fn generate_credential_hmac_from_bytes(
|
||||
credential_bytes: &[u8],
|
||||
network_secret: &str,
|
||||
) -> Vec<u8> {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(network_secret.as_bytes())
|
||||
.expect("HMAC can take key of any size");
|
||||
mac.update(b"easytier credential proof");
|
||||
mac.update(&credential.encode_to_vec());
|
||||
mac.update(credential_bytes);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn generate_credential_hmac(
|
||||
credential: &TrustedCredentialPubkey,
|
||||
network_secret: &str,
|
||||
) -> Vec<u8> {
|
||||
Self::generate_credential_hmac_from_bytes(&credential.encode_to_vec(), network_secret)
|
||||
}
|
||||
|
||||
pub fn new_signed(credential: TrustedCredentialPubkey, network_secret: &str) -> Self {
|
||||
let credential_hmac = Self::generate_credential_hmac(&credential, network_secret);
|
||||
Self {
|
||||
@@ -63,6 +70,14 @@ impl TrustedCredentialPubkeyProof {
|
||||
let Some(credential) = self.credential.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
self.verify_credential_hmac_with_bytes(&credential.encode_to_vec(), network_secret)
|
||||
}
|
||||
|
||||
pub fn verify_credential_hmac_with_bytes(
|
||||
&self,
|
||||
credential_bytes: &[u8],
|
||||
network_secret: &str,
|
||||
) -> bool {
|
||||
if self.credential_hmac.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -70,7 +85,7 @@ impl TrustedCredentialPubkeyProof {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(network_secret.as_bytes())
|
||||
.expect("HMAC can take key of any size");
|
||||
mac.update(b"easytier credential proof");
|
||||
mac.update(&credential.encode_to_vec());
|
||||
mac.update(credential_bytes);
|
||||
mac.verify_slice(&self.credential_hmac).is_ok()
|
||||
}
|
||||
}
|
||||
@@ -300,6 +315,7 @@ mod tests {
|
||||
allow_relay: true,
|
||||
expiry_unix: 123456,
|
||||
allowed_proxy_cidrs: vec!["10.0.0.0/24".to_string()],
|
||||
reusable: Some(true),
|
||||
};
|
||||
let tc = TrustedCredentialPubkeyProof::new_signed(credential, "sec-1");
|
||||
|
||||
@@ -315,6 +331,7 @@ mod tests {
|
||||
allow_relay: false,
|
||||
expiry_unix: 1,
|
||||
allowed_proxy_cidrs: vec![],
|
||||
reusable: Some(true),
|
||||
};
|
||||
let tc = TrustedCredentialPubkeyProof::new_signed(credential, "sec-1");
|
||||
|
||||
@@ -322,4 +339,35 @@ mod tests {
|
||||
tampered.credential.as_mut().unwrap().allow_relay = true;
|
||||
assert!(!tampered.verify_credential_hmac("sec-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trusted_credential_pubkey_hmac_with_raw_bytes() {
|
||||
let credential = TrustedCredentialPubkey {
|
||||
pubkey: vec![9u8; 32],
|
||||
groups: vec!["raw".to_string()],
|
||||
allow_relay: true,
|
||||
expiry_unix: 123456,
|
||||
allowed_proxy_cidrs: vec![],
|
||||
reusable: Some(true),
|
||||
};
|
||||
|
||||
let mut raw_credential_bytes = credential.encode_to_vec();
|
||||
prost::encoding::encode_key(
|
||||
9999,
|
||||
prost::encoding::WireType::Varint,
|
||||
&mut raw_credential_bytes,
|
||||
);
|
||||
prost::encoding::encode_varint(42, &mut raw_credential_bytes);
|
||||
|
||||
let proof = TrustedCredentialPubkeyProof {
|
||||
credential: Some(credential),
|
||||
credential_hmac: TrustedCredentialPubkeyProof::generate_credential_hmac_from_bytes(
|
||||
&raw_credential_bytes,
|
||||
"sec-1",
|
||||
),
|
||||
};
|
||||
|
||||
assert!(proof.verify_credential_hmac_with_bytes(&raw_credential_bytes, "sec-1"));
|
||||
assert!(!proof.verify_credential_hmac("sec-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::sync::{Arc, Mutex, atomic::AtomicBool};
|
||||
|
||||
use futures::{SinkExt as _, StreamExt};
|
||||
use guarden::defer;
|
||||
use tokio::{task::JoinSet, time::timeout};
|
||||
|
||||
use crate::{
|
||||
defer,
|
||||
proto::rpc_types::error::Error,
|
||||
tunnel::{Tunnel, packet_def::PacketType, ring::create_ring_tunnel_pair},
|
||||
};
|
||||
|
||||
@@ -4,18 +4,17 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use bytes::Bytes;
|
||||
use dashmap::DashMap;
|
||||
use guarden::defer;
|
||||
use prost::Message;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::timeout;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::common::shrink_dashmap;
|
||||
use crate::common::{
|
||||
PeerId,
|
||||
PeerId, shrink_dashmap,
|
||||
stats_manager::{LabelSet, LabelType, MetricName, StatsManager},
|
||||
};
|
||||
use crate::defer;
|
||||
use crate::proto::common::{
|
||||
CompressionAlgoPb, RpcCompressionInfo, RpcDescriptor, RpcPacket, RpcRequest, RpcResponse,
|
||||
};
|
||||
|
||||
@@ -424,11 +424,11 @@ async fn standalone_rpc_test() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bidirect_rpc_manager() {
|
||||
use crate::common::scoped_task::ScopedTask;
|
||||
use crate::proto::rpc_impl::bidirect::BidirectRpcManager;
|
||||
use crate::tunnel::tcp::{TcpTunnelConnector, TcpTunnelListener};
|
||||
use crate::tunnel::{TunnelConnector, TunnelListener};
|
||||
use tokio::sync::Notify;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
let c = BidirectRpcManager::new();
|
||||
let s = BidirectRpcManager::new();
|
||||
@@ -448,7 +448,7 @@ async fn test_bidirect_rpc_manager() {
|
||||
let server_test_done = Arc::new(Notify::new());
|
||||
let server_test_done_clone = server_test_done.clone();
|
||||
let mut tcp_listener = TcpTunnelListener::new("tcp://0.0.0.0:55443".parse().unwrap());
|
||||
let s_task: ScopedTask<()> = tokio::spawn(async move {
|
||||
let s_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
tcp_listener.listen().await.unwrap();
|
||||
let tunnel = tcp_listener.accept().await.unwrap();
|
||||
s.run_with_tunnel(tunnel);
|
||||
@@ -471,8 +471,7 @@ async fn test_bidirect_rpc_manager() {
|
||||
server_test_done_clone.notify_one();
|
||||
|
||||
s.wait().await;
|
||||
})
|
||||
.into();
|
||||
}));
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ message HeartbeatRequest {
|
||||
|
||||
repeated common.UUID running_network_instances = 7;
|
||||
DeviceOsInfo device_os = 8;
|
||||
bool support_config_source = 9;
|
||||
}
|
||||
|
||||
message HeartbeatResponse {}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
common::config::{ConfigFileControl, ConfigFilePermission, ConfigLoader},
|
||||
common::config::{ConfigFileControl, ConfigFilePermission, ConfigLoader, ConfigSource},
|
||||
instance_manager::NetworkInstanceManager,
|
||||
proto::{
|
||||
api::{config::GetConfigRequest, manage::*},
|
||||
api::{
|
||||
config::GetConfigRequest,
|
||||
manage::{
|
||||
CollectNetworkInfoRequest, CollectNetworkInfoResponse,
|
||||
DeleteNetworkInstanceRequest, DeleteNetworkInstanceResponse,
|
||||
GetNetworkInstanceConfigRequest, GetNetworkInstanceConfigResponse,
|
||||
ListNetworkInstanceMetaRequest, ListNetworkInstanceMetaResponse,
|
||||
ListNetworkInstanceRequest, ListNetworkInstanceResponse,
|
||||
NetworkInstanceRunningInfoMap, NetworkMeta, RetainNetworkInstanceRequest,
|
||||
RetainNetworkInstanceResponse, RunNetworkInstanceRequest,
|
||||
RunNetworkInstanceResponse, ValidateConfigRequest, ValidateConfigResponse,
|
||||
WebClientService,
|
||||
},
|
||||
},
|
||||
rpc_types::{self, controller::BaseController},
|
||||
},
|
||||
web_client::WebClientHooks,
|
||||
@@ -44,53 +57,64 @@ impl WebClientService for InstanceManageRpcService {
|
||||
return Err(anyhow::anyhow!("config is required").into());
|
||||
}
|
||||
let cfg = req.config.unwrap().gen_config()?;
|
||||
let id = cfg.get_id();
|
||||
let mut effective_id = cfg.get_id();
|
||||
if let Some(inst_id) = req.inst_id {
|
||||
cfg.set_id(inst_id.into());
|
||||
effective_id = inst_id.into();
|
||||
cfg.set_id(effective_id);
|
||||
}
|
||||
let requested_source = ConfigSource::from_rpc(req.source);
|
||||
let resp = RunNetworkInstanceResponse {
|
||||
inst_id: Some(id.into()),
|
||||
inst_id: Some(effective_id.into()),
|
||||
};
|
||||
|
||||
let mut control = if let Some(control) = self.manager.get_instance_config_control(&id) {
|
||||
let error_msg = self
|
||||
.manager
|
||||
.get_network_info(&id)
|
||||
.await
|
||||
.and_then(|i| i.error_msg)
|
||||
.unwrap_or_default();
|
||||
let mut control =
|
||||
if let Some(control) = self.manager.get_instance_config_control(&effective_id) {
|
||||
let existing_source = self
|
||||
.manager
|
||||
.get_instance_network_config_source(&effective_id);
|
||||
let error_msg = self
|
||||
.manager
|
||||
.get_network_info(&effective_id)
|
||||
.await
|
||||
.and_then(|i| i.error_msg)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !req.overwrite && error_msg.is_empty() {
|
||||
return Ok(resp);
|
||||
}
|
||||
if control.is_read_only() {
|
||||
return Err(
|
||||
anyhow::anyhow!("instance {} is read-only, cannot be overwritten", id).into(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(path) = control.path.as_ref() {
|
||||
let real_control = ConfigFileControl::from_path(path.clone()).await;
|
||||
if real_control.is_read_only() {
|
||||
if !req.overwrite && error_msg.is_empty() {
|
||||
return Ok(resp);
|
||||
}
|
||||
if control.is_read_only() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"config file {} is read-only, cannot be overwritten",
|
||||
path.display()
|
||||
"instance {} is read-only, cannot be overwritten",
|
||||
effective_id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
self.manager.delete_network_instance(vec![id])?;
|
||||
if let Some(path) = control.path.as_ref() {
|
||||
let real_control = ConfigFileControl::from_path(path.clone()).await;
|
||||
if real_control.is_read_only() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"config file {} is read-only, cannot be overwritten",
|
||||
path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
control.clone()
|
||||
} else if let Some(config_dir) = self.manager.get_config_dir() {
|
||||
ConfigFileControl::new(
|
||||
Some(config_dir.join(format!("{}.toml", id))),
|
||||
ConfigFilePermission::default(),
|
||||
)
|
||||
} else {
|
||||
ConfigFileControl::new(None, ConfigFilePermission::default())
|
||||
};
|
||||
self.manager.delete_network_instance(vec![effective_id])?;
|
||||
|
||||
cfg.set_network_config_source(requested_source.or(existing_source));
|
||||
control.clone()
|
||||
} else if let Some(config_dir) = self.manager.get_config_dir() {
|
||||
cfg.set_network_config_source(requested_source);
|
||||
ConfigFileControl::new(
|
||||
Some(config_dir.join(format!("{}.toml", effective_id))),
|
||||
ConfigFilePermission::default(),
|
||||
)
|
||||
} else {
|
||||
cfg.set_network_config_source(requested_source);
|
||||
ConfigFileControl::new(None, ConfigFilePermission::default())
|
||||
};
|
||||
|
||||
if !control.is_read_only()
|
||||
&& let Some(config_file) = control.path.as_ref()
|
||||
@@ -109,9 +133,9 @@ impl WebClientService for InstanceManageRpcService {
|
||||
}
|
||||
|
||||
self.manager.run_network_instance(cfg, true, control)?;
|
||||
println!("instance {} started", id);
|
||||
println!("instance {} started", effective_id);
|
||||
|
||||
if let Err(e) = self.hooks.post_run_network_instance(&id).await {
|
||||
if let Err(e) = self.hooks.post_run_network_instance(&effective_id).await {
|
||||
tracing::warn!("post-run hook failed: {}", e);
|
||||
}
|
||||
|
||||
@@ -261,7 +285,14 @@ impl WebClientService for InstanceManageRpcService {
|
||||
.get_config(BaseController::default(), GetConfigRequest::default())
|
||||
.await?
|
||||
.config;
|
||||
Ok(GetNetworkInstanceConfigResponse { config })
|
||||
Ok(GetNetworkInstanceConfigResponse {
|
||||
config,
|
||||
source: self
|
||||
.manager
|
||||
.get_instance_network_config_source(&inst_id)
|
||||
.unwrap_or(ConfigSource::User)
|
||||
.to_rpc(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_network_instance_meta(
|
||||
@@ -286,6 +317,11 @@ impl WebClientService for InstanceManageRpcService {
|
||||
network_name,
|
||||
config_permission: control.permission.into(),
|
||||
instance_name,
|
||||
source: self
|
||||
.manager
|
||||
.get_instance_network_config_source(&inst_id)
|
||||
.unwrap_or(ConfigSource::User)
|
||||
.to_rpc(),
|
||||
};
|
||||
metas.push(meta);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ use std::sync::Arc;
|
||||
use crate::{
|
||||
instance_manager::NetworkInstanceManager,
|
||||
proto::{
|
||||
api::instance::{self, ListPeerRequest, ListPeerResponse, PeerManageRpc},
|
||||
api::instance::{
|
||||
self, ListPeerRequest, ListPeerResponse, ListPublicIpv6InfoRequest,
|
||||
ListPublicIpv6InfoResponse, PeerManageRpc,
|
||||
},
|
||||
rpc_types::controller::BaseController,
|
||||
},
|
||||
};
|
||||
@@ -34,6 +37,17 @@ impl PeerManageRpc for PeerManageRpcService {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_public_ipv6_info(
|
||||
&self,
|
||||
ctrl: Self::Controller,
|
||||
req: ListPublicIpv6InfoRequest,
|
||||
) -> crate::proto::rpc_types::error::Result<ListPublicIpv6InfoResponse> {
|
||||
super::get_instance_service(&self.instance_manager, &req.instance)?
|
||||
.get_peer_manage_service()
|
||||
.list_public_ipv6_info(ctrl, req)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_route(
|
||||
&self,
|
||||
ctrl: Self::Controller,
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::proto::{api::manage::*, rpc_types::controller::BaseController};
|
||||
use crate::{
|
||||
common::config::ConfigSource,
|
||||
proto::{
|
||||
api::manage::{
|
||||
CollectNetworkInfoRequest, CollectNetworkInfoResponse, DeleteNetworkInstanceRequest,
|
||||
GetNetworkInstanceConfigRequest, ListNetworkInstanceMetaRequest,
|
||||
ListNetworkInstanceRequest, NetworkConfig, NetworkMeta, RunNetworkInstanceRequest,
|
||||
ValidateConfigRequest, ValidateConfigResponse, WebClientService,
|
||||
},
|
||||
rpc_types::controller::BaseController,
|
||||
},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait RemoteClientManager<T, C, E>
|
||||
@@ -52,6 +63,7 @@ where
|
||||
inst_id: None,
|
||||
config: Some(config.clone()),
|
||||
overwrite: true,
|
||||
source: ConfigSource::User.to_rpc(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -62,6 +74,7 @@ where
|
||||
identify,
|
||||
resp.inst_id.unwrap_or_default().into(),
|
||||
config,
|
||||
ConfigSource::User,
|
||||
)
|
||||
.await
|
||||
.map_err(RemoteClientError::PersistentError)?;
|
||||
@@ -162,13 +175,18 @@ where
|
||||
.get_rpc_client(identify.clone())
|
||||
.ok_or(RemoteClientError::ClientNotFound)?;
|
||||
|
||||
let cfg = self
|
||||
.handle_get_network_config(identify.clone(), inst_id)
|
||||
let (cfg, source) = self
|
||||
.handle_get_network_config_with_source(identify.clone(), inst_id)
|
||||
.await?;
|
||||
|
||||
if disabled {
|
||||
self.get_storage()
|
||||
.insert_or_update_user_network_config(identify.clone(), inst_id, cfg.clone())
|
||||
.insert_or_update_user_network_config(
|
||||
identify.clone(),
|
||||
inst_id,
|
||||
cfg.clone(),
|
||||
source,
|
||||
)
|
||||
.await
|
||||
.map_err(RemoteClientError::PersistentError)?;
|
||||
|
||||
@@ -188,6 +206,7 @@ where
|
||||
inst_id: Some(inst_id.into()),
|
||||
config: Some(cfg),
|
||||
overwrite: true,
|
||||
source: source.to_rpc(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -230,8 +249,8 @@ where
|
||||
if metas.contains_key(&instance_id) {
|
||||
continue;
|
||||
}
|
||||
let config = self
|
||||
.handle_get_network_config(identify.clone(), instance_id)
|
||||
let (config, source) = self
|
||||
.handle_get_network_config_with_source(identify.clone(), instance_id)
|
||||
.await?;
|
||||
let network_name = config.network_name.unwrap_or_default();
|
||||
metas.insert(
|
||||
@@ -241,6 +260,7 @@ where
|
||||
network_name: network_name.clone(),
|
||||
config_permission: 0,
|
||||
instance_name: network_name,
|
||||
source: source.to_rpc(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -255,7 +275,12 @@ where
|
||||
config: NetworkConfig,
|
||||
) -> Result<(), RemoteClientError<E>> {
|
||||
self.get_storage()
|
||||
.insert_or_update_user_network_config(identify.clone(), inst_id, config)
|
||||
.insert_or_update_user_network_config(
|
||||
identify.clone(),
|
||||
inst_id,
|
||||
config,
|
||||
ConfigSource::User,
|
||||
)
|
||||
.await
|
||||
.map_err(RemoteClientError::PersistentError)?;
|
||||
self.get_storage()
|
||||
@@ -270,6 +295,16 @@ where
|
||||
identify: T,
|
||||
inst_id: uuid::Uuid,
|
||||
) -> Result<NetworkConfig, RemoteClientError<E>> {
|
||||
self.handle_get_network_config_with_source(identify, inst_id)
|
||||
.await
|
||||
.map(|(config, _)| config)
|
||||
}
|
||||
|
||||
async fn handle_get_network_config_with_source(
|
||||
&self,
|
||||
identify: T,
|
||||
inst_id: uuid::Uuid,
|
||||
) -> Result<(NetworkConfig, ConfigSource), RemoteClientError<E>> {
|
||||
if let Some(client) = self.get_rpc_client(identify.clone())
|
||||
&& let Ok(resp) = client
|
||||
.get_network_instance_config(
|
||||
@@ -281,7 +316,17 @@ where
|
||||
.await
|
||||
&& let Some(config) = resp.config
|
||||
{
|
||||
return Ok(config);
|
||||
let source = if let Some(source) = ConfigSource::from_rpc(resp.source) {
|
||||
source
|
||||
} else {
|
||||
self.get_storage()
|
||||
.get_network_config(identify.clone(), &inst_id.to_string())
|
||||
.await
|
||||
.map_err(RemoteClientError::PersistentError)?
|
||||
.map(|cfg| cfg.get_runtime_network_config_source())
|
||||
.unwrap_or(ConfigSource::User)
|
||||
};
|
||||
return Ok((config, source));
|
||||
}
|
||||
|
||||
let inst_id = inst_id.to_string();
|
||||
@@ -296,9 +341,12 @@ where
|
||||
inst_id
|
||||
)))?;
|
||||
|
||||
Ok(db_row
|
||||
.get_network_config()
|
||||
.map_err(RemoteClientError::PersistentError)?)
|
||||
Ok((
|
||||
db_row
|
||||
.get_network_config()
|
||||
.map_err(RemoteClientError::PersistentError)?,
|
||||
db_row.get_runtime_network_config_source(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +384,10 @@ pub struct GetNetworkMetasResponse {
|
||||
pub trait PersistentConfig<E> {
|
||||
fn get_network_inst_id(&self) -> &str;
|
||||
fn get_network_config(&self) -> Result<NetworkConfig, E>;
|
||||
fn get_network_config_source(&self) -> ConfigSource;
|
||||
fn get_runtime_network_config_source(&self) -> ConfigSource {
|
||||
self.get_network_config_source()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -348,6 +400,7 @@ where
|
||||
identify: T,
|
||||
network_inst_id: Uuid,
|
||||
network_config: NetworkConfig,
|
||||
source: ConfigSource,
|
||||
) -> Result<(), E>;
|
||||
|
||||
async fn delete_network_configs(&self, identify: T, network_inst_ids: &[Uuid])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user