Merge branch 'main' into feat/dns-policy

# Conflicts:
#	easytier/src/common/config.rs
#	easytier/src/common/dns.rs
#	easytier/src/common/mod.rs
#	easytier/src/core.rs
#	easytier/src/instance/instance.rs
#	easytier/src/launcher.rs
#	easytier/src/peers/peer_ospf_route.rs
#	easytier/src/proto/peer_rpc.proto
#	easytier/src/utils/task.rs
This commit is contained in:
Luna Yao
2026-04-27 17:16:43 +02:00
124 changed files with 20386 additions and 2283 deletions
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+14 -7
View File
@@ -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
+6244 -193
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "easytier-gui",
"type": "module",
"version": "2.6.0",
"version": "2.6.2",
"private": true,
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"scripts": {
+1 -1
View File
@@ -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") {
+199 -30
View File
@@ -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"))]
+1 -1
View File
@@ -17,7 +17,7 @@
"createUpdaterArtifacts": false
},
"productName": "easytier-gui",
"version": "2.6.0",
"version": "2.6.2",
"identifier": "com.kkrainbow.easytier",
"plugins": {
"shell": {
+39 -2
View File
@@ -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
})
}
+13 -2
View File
@@ -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 {
+1
View File
@@ -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 {
+20 -3
View File
@@ -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;
+2 -1
View File
@@ -1,6 +1,6 @@
[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."
@@ -10,6 +10,7 @@ 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"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { AutoComplete, Button, Checkbox, Dialog, Divider, InputNumber, InputText, Panel, Password, SelectButton, ToggleButton } from 'primevue'
import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon'
import { Checkbox, InputText, InputNumber, AutoComplete, Panel, Divider, ToggleButton, Button, Password, Dialog } from 'primevue'
import {
addRow,
DEFAULT_NETWORK_CONFIG,
@@ -11,6 +11,7 @@ import {
} from '../types/network'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AclManager from './acl/AclManager.vue'
import UrlListInput from './UrlListInput.vue'
const props = defineProps<{
@@ -488,6 +489,18 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa
</div>
</Panel>
<Divider />
<Panel :header="t('acl.title')" toggleable collapsed>
<div v-if="curNetwork.acl" class="flex flex-col gap-y-2">
<AclManager v-model="curNetwork.acl" />
</div>
<div v-else class="flex justify-center p-4">
<Button :label="t('acl.enabled')"
@click="curNetwork.acl = { acl_v1: { chains: [], group: { declares: [], members: [] } } }" />
</div>
</Panel>
<div class="flex pt-6 justify-center">
<Button :label="t('run_network')" icon="pi pi-arrow-right" icon-pos="right" :disabled="configInvalid"
@click="$emit('runNetwork', curNetwork)" />
@@ -32,7 +32,7 @@ onMounted(() => {
}
})
const parseUrl = (val: string | null | undefined) => {
const parseUrl = (val: string | null | undefined): { proto: string; host: string; port: number | null } => {
const getValidPort = (portStr: string, proto: string) => {
const p = parseInt(portStr)
return isNaN(p) ? (props.protos[proto] ?? 11010) : p
@@ -55,13 +55,16 @@ const parseUrl = (val: string | null | undefined) => {
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)
// null = no explicit port in URL; do not fabricate a default
const port: number | null = remain.startsWith(':') ? getValidPort(remain.slice(1), proto) : null
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)
// null = no explicit port in URL; buildUrlValue will omit the port entirely,
// preserving the protocol's implied standard port (e.g. 443 for wss://).
const port: number | null = portMatch ? parseInt(portMatch[2]) : null
return { proto, host, port }
}
@@ -72,28 +75,26 @@ const parseUrl = (val: string | null | undefined) => {
if (parsedByPattern) {
return parsedByPattern
}
return { proto: 'tcp', host: '', port: 11010 }
return { proto: 'tcp', host: '', port: null }
}
const internalValue = ref(parseUrl(url.value))
const defaultHost = '0.0.0.0'
const buildUrlValue = (value: { proto: string, host: string, port: number }, forceDefaultHost = false) => {
const buildUrlValue = (value: { proto: string, host: string, port: number | null }, 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) {
// Omit port when the protocol uses no port (protos value = 0), or when the
// original URL had no explicit port (port === null) avoids overwriting an
// implicit standard port (e.g. 443 for wss) with an EasyTier default (11012).
if (props.protos[proto] === 0 || value.port === null) {
return `${proto}://${host}`
}
return `${proto}://${host}:${port}`
return `${proto}://${host}:${value.port}`
}
const syncUrlFromInternal = (forceDefaultHost = false) => {
@@ -180,6 +181,7 @@ const onProtoChange = (newProto: string) => {
<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>
@@ -207,7 +209,8 @@ const onProtoChange = (newProto: string) => {
</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,218 @@
<script setup lang="ts">
import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AclAction, AclChain, AclChainType, AclProtocol, AclRule } from '../../types/network'
import AclRuleDialog from './AclRuleDialog.vue'
const props = defineProps<{
groupNames?: string[]
}>()
const chain = defineModel<AclChain>({ required: true })
const { t } = useI18n()
watch(() => chain.value.rules, (newRules) => {
if (!newRules) return
const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0))
if (!isSorted) {
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
}
}, { deep: true, immediate: true })
const actionOptions = [
{ label: () => t('acl.allow'), value: AclAction.Allow },
{ label: () => t('acl.drop'), value: AclAction.Drop },
]
const chainTypeOptions = [
{ label: () => t('acl.inbound'), value: AclChainType.Inbound },
{ label: () => t('acl.outbound'), value: AclChainType.Outbound },
{ label: () => t('acl.forward'), value: AclChainType.Forward },
]
const editingRule = ref<AclRule | null>(null)
const editingRuleIndex = ref(-1)
const showRuleDialog = ref(false)
function getProtocolLabel(proto: AclProtocol) {
switch (proto) {
case AclProtocol.Any: return t('acl.any')
case AclProtocol.TCP: return 'TCP'
case AclProtocol.UDP: return 'UDP'
case AclProtocol.ICMP: return 'ICMP'
case AclProtocol.ICMPv6: return 'ICMPv6'
default: return t('event.Unknown')
}
}
function getActionLabel(action: AclAction) {
switch (action) {
case AclAction.Allow: return t('acl.allow')
case AclAction.Drop: return t('acl.drop')
default: return t('event.Unknown')
}
}
function addRule() {
editingRuleIndex.value = -1
editingRule.value = {
name: '',
description: '',
priority: chain.value.rules.length,
enabled: true,
protocol: AclProtocol.Any,
ports: [],
source_ips: [],
destination_ips: [],
source_ports: [],
action: AclAction.Allow,
rate_limit: 0,
burst_limit: 0,
stateful: false,
source_groups: [],
destination_groups: [],
}
showRuleDialog.value = true
}
function editRule(index: number) {
editingRuleIndex.value = index
editingRule.value = JSON.parse(JSON.stringify(chain.value.rules[index]))
showRuleDialog.value = true
}
function deleteRule(index: number) {
chain.value.rules.splice(index, 1)
}
function saveRule(rule: AclRule) {
if (editingRuleIndex.value === -1) {
chain.value.rules.push(rule)
} else {
chain.value.rules[editingRuleIndex.value] = rule
}
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
}
function onRowReorder(event: any) {
chain.value.rules = event.value
// Update priorities based on new order (higher priority at top)
chain.value.rules.forEach((rule, index) => {
rule.priority = chain.value.rules.length - index - 1
})
}
</script>
<template>
<div class="flex flex-col gap-6">
<!-- Chain Metadata Section -->
<div
class="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-700">
<div class="flex flex-col gap-2">
<label class="font-bold text-sm">{{ t('acl.chain.name') }}</label>
<InputText v-model="chain.name" size="small" />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold text-sm">{{ t('acl.rule.description') }}</label>
<InputText v-model="chain.description" size="small" />
</div>
<div class="flex items-center gap-6 col-span-full border-t pt-2 mt-2 dark:border-gray-700">
<div class="flex items-center gap-2">
<label class="font-bold text-sm">{{ t('acl.rule.enabled') }}</label>
<ToggleButton v-model="chain.enabled" on-icon="pi pi-check" off-icon="pi pi-times"
:on-label="t('web.common.enable')" :off-label="t('web.common.disable')" class="w-24" />
</div>
<div class="flex items-center gap-2">
<label class="font-bold text-sm">{{ t('acl.chain.type') }}</label>
<Select v-model="chain.chain_type" :options="chainTypeOptions" :option-label="opt => opt.label()"
option-value="value" size="small" class="w-40" />
</div>
<div class="flex items-center gap-2 ml-auto">
<label class="font-bold text-sm">{{ t('acl.default_action') }}</label>
<SelectButton v-model="chain.default_action" :options="actionOptions" :option-label="opt => opt.label()"
option-value="value" :allow-empty="false" />
</div>
</div>
</div>
<div class="flex flex-row items-center gap-4 justify-between">
<h4 class="text-md font-bold">{{ t('acl.rules') }}</h4>
<Button icon="pi pi-plus" :label="t('acl.add_rule')" severity="success" size="small" @click="addRule" />
</div>
<DataTable :value="chain.rules" @row-reorder="onRowReorder" responsiveLayout="scroll">
<Column rowReorder headerStyle="width: 3rem" />
<Column field="enabled" :header="t('acl.rule.enabled')">
<template #body="{ data }">
<i class="pi" :class="data.enabled ? 'pi-check-circle text-green-500' : 'pi-times-circle text-red-500'"></i>
</template>
</Column>
<Column field="name" :header="t('acl.rule.name')" />
<Column :header="t('acl.match')">
<template #body="{ data }">
<div class="flex flex-col gap-2 py-1">
<div class="flex items-center gap-2">
<span
class="px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded-md text-[10px] font-bold uppercase tracking-wider">
{{ getProtocolLabel(data.protocol) }}
</span>
</div>
<div class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-[10px] font-bold text-gray-400 uppercase w-7">Src</span>
<div class="flex flex-wrap gap-1 items-center overflow-hidden">
<span v-for="ip in data.source_ips" :key="ip"
class="font-mono text-xs bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded">{{ ip }}</span>
<span v-for="grp in data.source_groups" :key="grp"
class="text-xs font-bold text-purple-600 dark:text-purple-400">@{{ grp }}</span>
<span v-if="data.source_ports.length" class="text-xs text-blue-600 dark:text-blue-400 font-mono">:{{
data.source_ports.join(',') }}</span>
<span v-if="!data.source_ips.length && !data.source_groups.length" class="text-gray-400">*</span>
</div>
</div>
<i class="pi pi-arrow-right hidden sm:block text-gray-300 text-xs"></i>
<Divider layout="horizontal" class="sm:hidden my-1" />
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-[10px] font-bold text-gray-400 uppercase w-7">Dst</span>
<div class="flex flex-wrap gap-1 items-center overflow-hidden">
<span v-for="ip in data.destination_ips" :key="ip"
class="font-mono text-xs bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded">{{ ip }}</span>
<span v-for="grp in data.destination_groups" :key="grp"
class="text-xs font-bold text-purple-600 dark:text-purple-400">@{{ grp }}</span>
<span v-if="data.ports.length" class="text-xs text-blue-600 dark:text-blue-400 font-mono">:{{
data.ports.join(',') }}</span>
<span v-if="!data.destination_ips.length && !data.destination_groups.length"
class="text-gray-400">*</span>
</div>
</div>
</div>
</div>
</template>
</Column>
<Column field="action" :header="t('acl.rule.action')">
<template #body="{ data }">
<span :class="data.action === AclAction.Allow ? 'text-green-600' : 'text-red-600 font-bold'">
{{ getActionLabel(data.action) }}
</span>
</template>
</Column>
<Column :header="t('web.common.edit')">
<template #body="{ index }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" text rounded @click="editRule(index)" />
<Button icon="pi pi-trash" severity="danger" text rounded @click="deleteRule(index)" />
</div>
</template>
</Column>
</DataTable>
<AclRuleDialog v-if="showRuleDialog && editingRule" v-model:visible="showRuleDialog" v-model:rule="editingRule"
:group-names="props.groupNames" @save="saveRule" />
</div>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { Button, Column, DataTable, Dialog, InputText, MultiSelect, Password } from 'primevue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { GroupIdentity, GroupInfo } from '../../types/network';
const props = defineProps<{
groupNames?: string[]
}>()
const group = defineModel<GroupInfo>({ required: true })
const emit = defineEmits(['rename-group'])
const { t } = useI18n()
const editingGroup = ref<GroupIdentity | null>(null)
const editingGroupIndex = ref(-1)
const showGroupDialog = ref(false)
const oldGroupName = ref('')
function addGroup() {
editingGroupIndex.value = -1
editingGroup.value = {
group_name: '',
group_secret: '',
}
oldGroupName.value = ''
showGroupDialog.value = true
}
function editGroup(index: number) {
editingGroupIndex.value = index
editingGroup.value = JSON.parse(JSON.stringify(group.value.declares[index]))
oldGroupName.value = editingGroup.value?.group_name || ''
showGroupDialog.value = true
}
function deleteGroup(index: number) {
group.value.declares.splice(index, 1)
}
function saveGroup() {
if (!editingGroup.value) return
const newName = editingGroup.value.group_name
if (editingGroupIndex.value === -1) {
group.value.declares.push(editingGroup.value)
} else {
if (oldGroupName.value && oldGroupName.value !== newName) {
// Sync in members
group.value.members = group.value.members.map(m => m === oldGroupName.value ? newName : m)
// Notify parent to sync in rules
emit('rename-group', { oldName: oldGroupName.value, newName })
}
group.value.declares[editingGroupIndex.value] = editingGroup.value
}
showGroupDialog.value = false
}
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center">
<div class="flex flex-col">
<label class="font-bold text-lg">{{ t('acl.group.declares') }}</label>
<small class="text-gray-500">{{ t('acl.group.help') }}</small>
</div>
<Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" />
</div>
<DataTable :value="group.declares" responsiveLayout="scroll">
<Column field="group_name" :header="t('acl.group.name')" />
<Column field="group_secret" :header="t('acl.group.secret')">
<template #body="{ data }">
<Password v-model="data.group_secret" :feedback="false" toggleMask readonly plain class="w-full" />
</template>
</Column>
<Column :header="t('web.common.edit')" headerStyle="width: 8rem">
<template #body="{ index }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" text rounded @click="editGroup(index)" />
<Button icon="pi pi-trash" severity="danger" text rounded @click="deleteGroup(index)" />
</div>
</template>
</Column>
</DataTable>
</div>
<div class="flex flex-col gap-2">
<label class="font-bold text-lg">{{ t('acl.group.members') }}</label>
<MultiSelect v-model="group.members" :options="props.groupNames" multiple fluid filter
:placeholder="t('acl.group.members')" />
</div>
<!-- Group Identity Dialog -->
<Dialog v-model:visible="showGroupDialog" modal :header="t('acl.groups')" :style="{ width: '400px' }">
<div v-if="editingGroup" class="flex flex-col gap-4 pt-2">
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.group.name') }}</label>
<InputText v-model="editingGroup.group_name" fluid />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.group.secret') }}</label>
<Password v-model="editingGroup.group_secret" :feedback="false" toggleMask fluid />
</div>
</div>
<template #footer>
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="showGroupDialog = false" text />
<Button :label="t('web.common.save')" icon="pi pi-save" @click="saveGroup" />
</template>
</Dialog>
</div>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Acl, AclAction, AclChainType } from '../../types/network'
import AclChainEditor from './AclChainEditor.vue'
import AclGroupEditor from './AclGroupEditor.vue'
const acl = defineModel<Acl>({ required: true })
const { t } = useI18n()
const activeTab = ref(0)
const menu = ref()
const addMenuModel = ref([
{ label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) },
{ label: () => t('acl.outbound'), command: () => addChain(AclChainType.Outbound) },
{ label: () => t('acl.forward'), command: () => addChain(AclChainType.Forward) },
])
function addChain(type: AclChainType) {
if (!acl.value.acl_v1) {
acl.value.acl_v1 = { chains: [], group: { declares: [], members: [] } }
}
let defaultName = ''
switch (type) {
case AclChainType.Inbound: defaultName = 'Inbound'; break;
case AclChainType.Outbound: defaultName = 'Outbound'; break;
case AclChainType.Forward: defaultName = 'Forward'; break;
}
acl.value.acl_v1.chains.push({
name: defaultName,
chain_type: type,
description: '',
enabled: true,
rules: [],
default_action: AclAction.Allow
})
activeTab.value = acl.value.acl_v1.chains.length - 1
}
function removeChain(index: number) {
if (confirm(t('acl.delete_chain_confirm'))) {
acl.value.acl_v1?.chains.splice(index, 1)
if (activeTab.value >= (acl.value.acl_v1?.chains.length || 0)) {
activeTab.value = Math.max(0, (acl.value.acl_v1?.chains.length || 0))
}
}
}
function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) {
if (!acl.value.acl_v1) return
acl.value.acl_v1.chains.forEach(chain => {
chain.rules.forEach(rule => {
rule.source_groups = rule.source_groups.map(g => g === oldName ? newName : g)
rule.destination_groups = rule.destination_groups.map(g => g === oldName ? newName : g)
})
})
}
const groupNames = computed(() => {
return acl.value.acl_v1?.group?.declares.map(g => g.group_name) || []
})
const tabs = computed(() => {
const chains = acl.value.acl_v1?.chains || []
const result: { type: string, label: string, index: number }[] = []
if (chains.length === 0) {
result.push({ type: 'empty', label: t('acl.chains'), index: 0 })
}
else {
chains.forEach((c, index) => {
result.push({
type: 'chain',
label: c.name || `Chain ${index}`,
index
})
})
}
result.push({ type: 'groups', label: t('acl.groups'), index: result.length })
return result
})
</script>
<template>
<div class="flex flex-col gap-4">
<Tabs v-model:value="activeTab">
<div class="flex items-center border-b border-surface-200 dark:border-surface-700">
<TabList class="flex-grow min-w-0 overflow-x-auto" style="border-bottom: none;">
<Tab v-for="tab in tabs" :key="tab.type + tab.index" :value="tab.index">
<div class="flex items-center gap-2 whitespace-nowrap">
{{ tab.label }}
<Button v-if="tab.type === 'chain'" icon="pi pi-times" severity="danger" text rounded size="small"
class="w-6 h-6 p-0" @click.stop="removeChain(tab.index)" />
</div>
</Tab>
</TabList>
<div
class="flex-shrink-0 flex items-center px-2 bg-white dark:bg-gray-900 border-l border-surface-100 dark:border-surface-800">
<Button icon="pi pi-plus" text rounded size="small" class="w-8 h-8 p-0"
@click="(event) => menu.toggle(event)" />
<Menu ref="menu" :model="addMenuModel" :popup="true" />
</div>
</div>
<TabPanels>
<TabPanel v-for="tab in tabs" :key="'panel' + tab.type + tab.index" :value="tab.index">
<!-- Empty State within TabPanel -->
<div v-if="tab.type === 'empty'"
class="py-8 flex flex-col items-center justify-center border-2 border-dashed border-surface-200 rounded-lg bg-surface-50 dark:bg-surface-900 dark:border-surface-700">
<i class="pi pi-shield text-5xl mb-4 text-primary" />
<div class="text-xl font-bold mb-2">{{ t('acl.chains') }}</div>
<p class="text-surface-500 mb-8 text-center max-w-sm px-4">{{ t('acl.help') }}</p>
<div class="flex flex-wrap gap-3 justify-center">
<Button :label="t('acl.inbound')" icon="pi pi-arrow-down-left" @click="addChain(AclChainType.Inbound)" />
<Button :label="t('acl.outbound')" icon="pi pi-arrow-up-right" @click="addChain(AclChainType.Outbound)" />
<Button :label="t('acl.forward')" icon="pi pi-directions" @click="addChain(AclChainType.Forward)" />
</div>
</div>
<!-- Rule Chains -->
<div v-if="tab.type === 'chain' && acl.acl_v1 && acl.acl_v1.chains[tab.index]" class="py-4">
<AclChainEditor v-model="acl.acl_v1.chains[tab.index]" :group-names="groupNames" />
</div>
<!-- Group Management -->
<div v-if="tab.type === 'groups'" class="py-4">
<template v-if="acl.acl_v1">
<AclGroupEditor v-if="acl.acl_v1.group" v-model="acl.acl_v1.group" :group-names="groupNames"
@rename-group="handleRenameGroup" />
<div v-else class="flex justify-center p-4">
<Button :label="t('web.common.add') + ' ' + t('acl.groups')"
@click="acl.acl_v1.group = { declares: [], members: [] }" />
</div>
</template>
<div v-else class="flex justify-center p-4">
<Button :label="t('acl.enabled')"
@click="acl.acl_v1 = { chains: [], group: { declares: [], members: [] } }" />
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { AutoComplete, Button, Checkbox, Dialog, InputNumber, InputText, MultiSelect, Panel, SelectButton, ToggleButton } from 'primevue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { AclAction, AclProtocol, AclRule } from '../../types/network';
const props = defineProps<{
visible: boolean
groupNames?: string[]
}>()
const emit = defineEmits(['update:visible', 'save'])
const rule = defineModel<AclRule>('rule', { required: true })
const { t } = useI18n()
const protocolOptions = [
{ label: () => t('acl.any'), value: AclProtocol.Any },
{ label: 'TCP', value: AclProtocol.TCP },
{ label: 'UDP', value: AclProtocol.UDP },
{ label: 'ICMP', value: AclProtocol.ICMP },
{ label: 'ICMPv6', value: AclProtocol.ICMPv6 },
]
const actionOptions = [
{ label: () => t('acl.allow'), value: AclAction.Allow },
{ label: () => t('acl.drop'), value: AclAction.Drop },
]
const showPorts = computed(() => {
return rule.value.protocol === AclProtocol.TCP || rule.value.protocol === AclProtocol.UDP || rule.value.protocol === AclProtocol.Any
})
function close() {
emit('update:visible', false)
}
function save() {
emit('save', rule.value)
close()
}
// Suggestions for IP/Port AutoComplete
const genericSuggestions = ref<string[]>([])
</script>
<template>
<Dialog :visible="visible" @update:visible="emit('update:visible', $event)" modal :header="t('acl.edit_rule')"
:style="{ width: '90vw', maxWidth: '600px' }">
<div class="flex flex-col gap-4">
<div class="flex flex-row gap-4 items-center">
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.name') }}</label>
<InputText v-model="rule.name" fluid />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.enabled') }}</label>
<ToggleButton v-model="rule.enabled" on-icon="pi pi-check" off-icon="pi pi-times"
:on-label="t('web.common.enable')" :off-label="t('web.common.disable')" class="w-24" />
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.description') }}</label>
<InputText v-model="rule.description" fluid />
</div>
<div class="flex flex-row gap-4 flex-wrap">
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.action') }}</label>
<SelectButton v-model="rule.action" :options="actionOptions" :option-label="opt => opt.label()"
option-value="value" :allow-empty="false" />
</div>
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.protocol') }}</label>
<SelectButton v-model="rule.protocol" :options="protocolOptions"
:option-label="opt => typeof opt.label === 'function' ? opt.label() : opt.label" option-value="value"
:allow-empty="false" />
</div>
</div>
<Panel :header="t('acl.rules')" toggleable>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.src_ips') }}</label>
<AutoComplete v-model="rule.source_ips" multiple fluid :suggestions="genericSuggestions"
@complete="genericSuggestions = [$event.query]"
:placeholder="t('chips_placeholder', ['10.126.126.0/24'])" />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.dst_ips') }}</label>
<AutoComplete v-model="rule.destination_ips" multiple fluid :suggestions="genericSuggestions"
@complete="genericSuggestions = [$event.query]"
:placeholder="t('chips_placeholder', ['10.126.126.2/32'])" />
</div>
<div v-if="showPorts" class="flex flex-row gap-4 flex-wrap">
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.src_ports') }}</label>
<AutoComplete v-model="rule.source_ports" multiple fluid :suggestions="genericSuggestions"
@complete="genericSuggestions = [$event.query]" placeholder="e.g. 80, 1000-2000" />
</div>
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.dst_ports') }}</label>
<AutoComplete v-model="rule.ports" multiple fluid :suggestions="genericSuggestions"
@complete="genericSuggestions = [$event.query]" placeholder="e.g. 80, 1000-2000" />
</div>
</div>
</div>
</Panel>
<Panel :header="t('advanced_settings')" toggleable collapsed>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Checkbox v-model="rule.stateful" :binary="true" inputId="rule-stateful" />
<label for="rule-stateful" class="font-bold">{{ t('acl.rule.stateful') }}</label>
</div>
<div class="flex flex-row gap-4 flex-wrap">
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.rate_limit') }}</label>
<InputNumber v-model="rule.rate_limit" :min="0" placeholder="0 = no limit" fluid />
</div>
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.burst_limit') }}</label>
<InputNumber v-model="rule.burst_limit" :min="0" placeholder="0 = no limit" fluid />
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.src_groups') }}</label>
<MultiSelect v-model="rule.source_groups" :options="props.groupNames" multiple fluid filter
:placeholder="t('acl.rule.src_groups')" />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.dst_groups') }}</label>
<MultiSelect v-model="rule.destination_groups" :options="props.groupNames" multiple fluid filter
:placeholder="t('acl.rule.dst_groups')" />
</div>
</div>
</Panel>
</div>
<template #footer>
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="close" text />
<Button :label="t('web.common.save')" icon="pi pi-save" @click="save" />
</template>
</Dialog>
</template>
@@ -355,6 +355,7 @@ web:
delete: 删除
edit: 编辑
refresh: 刷新
add: 添加
loading: 加载中...
error: 错误
success: 成功
@@ -422,3 +423,46 @@ config-server:
client:
not_running: 无法连接至远程客户端
retry: 重试
acl:
title: 访问控制
help: 访问控制列表,用于限制节点间的通信。
enabled: 启用 ACL
default_action: 默认动作
chains: 规则链
inbound: 入站
outbound: 出站
forward: 转发
rules: 规则
add_rule: 添加规则
edit_rule: 编辑规则
rule:
name: 规则名称
description: 描述
enabled: 启用
protocol: 协议
action: 动作
src_ips: 来源 IP
dst_ips: 目的 IP
src_ports: 来源端口
dst_ports: 目的端口
rate_limit: 速率限制 (pps)
burst_limit: 爆发限制
stateful: 状态追踪
src_groups: 来源组
dst_groups: 目的组
groups: 组管理
group:
declares: 声明组
members: 加入组
name: 组名
secret: 密钥
help: 在此处定义网络中的组身份,以便在规则中使用。
any: 任意
allow: 允许
drop: 丢弃
delete_chain_confirm: 确定要删除此规则链及其所有规则吗?
chain:
name: 名称
type: 类型
match: 匹配
@@ -355,6 +355,7 @@ web:
delete: Delete
edit: Edit
refresh: Refresh
add: Add
loading: Loading...
error: Error
success: Success
@@ -422,3 +423,46 @@ config-server:
client:
not_running: Unable to connect to remote client.
retry: Retry
acl:
title: Access Control (ACL)
help: Access control list to restrict communication between nodes.
enabled: Enable ACL
default_action: Default Action
chains: Rule Chains
inbound: Inbound
outbound: Outbound
forward: Forward
rules: Rules
add_rule: Add Rule
edit_rule: Edit Rule
rule:
name: Rule Name
description: Description
enabled: Enabled
protocol: Protocol
action: Action
src_ips: Source IPs
dst_ips: Destination IPs
src_ports: Source Ports
dst_ports: Destination Ports
rate_limit: Rate Limit (pps)
burst_limit: Burst Limit
stateful: Stateful
src_groups: Source Groups
dst_groups: Destination Groups
groups: Groups
group:
declares: Declared Groups
members: Node Memberships
name: Group Name
secret: Group Secret
help: Define group identities in the network to use them in rules.
any: Any
allow: Allow
drop: Drop
delete_chain_confirm: Are you sure you want to delete this rule chain and all its rules?
chain:
name: Name
type: Type
match: Match
@@ -14,6 +14,74 @@ export interface SecureModeConfig {
local_public_key?: string
}
export enum AclProtocol {
Unspecified = 0,
TCP = 1,
UDP = 2,
ICMP = 3,
ICMPv6 = 4,
Any = 5,
}
export enum AclAction {
Noop = 0,
Allow = 1,
Drop = 2,
}
export enum AclChainType {
UnspecifiedChain = 0,
Inbound = 1,
Outbound = 2,
Forward = 3,
}
export interface AclRule {
name: string
description: string
priority: number
enabled: boolean
protocol: AclProtocol
ports: string[]
source_ips: string[]
destination_ips: string[]
source_ports: string[]
action: AclAction
rate_limit: number
burst_limit: number
stateful: boolean
source_groups: string[]
destination_groups: string[]
}
export interface AclChain {
name: string
chain_type: AclChainType
description: string
enabled: boolean
rules: AclRule[]
default_action: AclAction
}
export interface GroupIdentity {
group_name: string
group_secret: string
}
export interface GroupInfo {
declares: GroupIdentity[]
members: string[]
}
export interface AclV1 {
chains: AclChain[]
group?: GroupInfo
}
export interface Acl {
acl_v1?: AclV1
}
export interface NetworkConfig {
instance_id: string
@@ -85,6 +153,7 @@ export interface NetworkConfig {
enable_private_mode?: boolean
port_forwards: PortForwardConfig[]
acl?: Acl
}
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
@@ -152,6 +221,15 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
enable_magic_dns: false,
enable_private_mode: false,
port_forwards: [],
acl: {
acl_v1: {
group: {
declares: [],
members: [],
},
chains: [],
},
},
}
}
+14 -7
View File
@@ -379,19 +379,26 @@ mod tests {
let req = tokio::time::timeout(Duration::from_secs(12), async {
loop {
let session = mgr
let sessions = mgr
.client_sessions
.iter()
.next()
.map(|item| item.value().clone());
let Some(session) = session else {
.map(|item| item.value().clone())
.collect::<Vec<_>>();
if sessions.is_empty() {
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
};
let mut waiter = session.data().read().await.heartbeat_waiter();
if let Ok(req) = waiter.recv().await {
}
let mut found_req = None;
for session in sessions {
if let Some(req) = session.data().read().await.req() {
found_req = Some(req);
break;
}
}
if let Some(req) = found_req {
break req;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
+733 -45
View File
@@ -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)?;
@@ -386,7 +466,7 @@ impl WebServerService for SessionRpcService {
_: easytier::proto::web::GetFeatureRequest,
) -> rpc_types::error::Result<easytier::proto::web::GetFeatureResponse> {
Ok(easytier::proto::web::GetFeatureResponse {
support_encryption: true,
support_encryption: easytier::web_client::security::web_secure_tunnel_supported(),
})
}
}
@@ -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)
}
}
+73 -8
View File
@@ -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
View File
@@ -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),
]
}
}
+10 -13
View File
@@ -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))
}
+5 -6
View File
@@ -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)
}
+11 -11
View File
@@ -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"
@@ -254,6 +256,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"
@@ -274,11 +278,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",
@@ -287,14 +295,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]
+12
View File
@@ -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连接。"
+19 -16
View File
@@ -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()?;
+257 -1
View File
@@ -19,9 +19,10 @@ use crate::{
common::stun::StunInfoCollector,
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},
utils,
};
@@ -71,9 +72,40 @@ pub fn gen_default_flags() -> Flags {
disable_sym_hole_punching: false,
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 {
@@ -151,6 +183,15 @@ pub trait ConfigLoader: Send + Sync + DnsConfigLoaderExt {
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);
@@ -218,6 +259,11 @@ pub trait ConfigLoader: Send + Sync + DnsConfigLoaderExt {
}
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;
}
@@ -237,6 +283,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,
@@ -446,6 +541,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>>,
@@ -481,6 +579,7 @@ struct Config {
stun_servers_v6: Option<Vec<String>>,
credential_file: Option<PathBuf>,
source: Option<ConfigSourceConfig>,
}
#[derive(Debug, Clone)]
@@ -495,10 +594,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 {
@@ -624,6 +734,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()
}
@@ -888,6 +1035,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 =
@@ -909,6 +1073,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;
@@ -1147,6 +1312,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#"
-26
View File
@@ -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)* });
};
}
+82 -6
View File
@@ -61,6 +61,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)
@@ -71,6 +76,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),
@@ -203,6 +210,7 @@ pub struct GlobalCtx {
cached_ipv4: AtomicCell<Option<cidr::Ipv4Inet>>,
cached_ipv6: AtomicCell<Option<cidr::Ipv6Inet>>,
public_ipv6_lease: AtomicCell<Option<cidr::Ipv6Inet>>,
cached_proxy_cidrs: AtomicCell<Option<Vec<ProxyNetworkConfig>>>,
ip_collector: Mutex<Option<Arc<IPCollector>>>,
@@ -215,6 +223,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>,
@@ -301,6 +310,7 @@ impl GlobalCtx {
event_bus,
cached_ipv4: AtomicCell::new(None),
cached_ipv6: AtomicCell::new(None),
public_ipv6_lease: AtomicCell::new(None),
cached_proxy_cidrs: AtomicCell::new(None),
ip_collector: Mutex::new(Some(Arc::new(IPCollector::new(
@@ -316,6 +326,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)),
@@ -390,6 +401,36 @@ 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 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 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()
}
@@ -404,7 +445,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),
}
}
@@ -654,23 +695,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()))
@@ -822,6 +863,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]
@@ -841,6 +883,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 {
+11
View File
@@ -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)
}
+200 -16
View File
@@ -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));
}
}
+9 -8
View File
@@ -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())
+1 -2
View File
@@ -14,7 +14,6 @@ pub mod acl_processor;
pub mod compressor;
pub mod config;
pub mod constants;
pub mod defer;
pub mod env_parser;
pub mod error;
pub mod global_ctx;
@@ -24,12 +23,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,
-119
View File
@@ -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());
}
}
+3 -4
View File
@@ -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),
}
}
+24 -9
View File
@@ -271,7 +271,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;
@@ -907,6 +906,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>;
}
@@ -970,6 +973,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
@@ -995,7 +1006,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() {
@@ -1311,6 +1321,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;
@@ -1321,11 +1338,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::*;
@@ -1419,7 +1434,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();
@@ -1443,7 +1458,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;
@@ -1482,7 +1497,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 -5
View File
@@ -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),
}
}
+786
View File
@@ -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()
));
}
}
+198 -40
View File
@@ -2,7 +2,7 @@
use std::{
collections::HashSet,
net::{IpAddr, Ipv6Addr, SocketAddr},
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
str::FromStr,
sync::{
Arc,
@@ -24,7 +24,7 @@ use crate::{
proto::{
peer_rpc::{
DirectConnectorRpc, DirectConnectorRpcClientFactory, DirectConnectorRpcServer,
GetIpListRequest, GetIpListResponse, SendV6HolePunchPacketRequest,
GetIpListRequest, GetIpListResponse, SendUdpHolePunchPacketRequest,
},
rpc_types::controller::BaseController,
},
@@ -49,6 +49,19 @@ 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
}
#[async_trait::async_trait]
pub trait PeerManagerForDirectConnector {
async fn list_peers(&self) -> Vec<PeerId>;
@@ -115,37 +128,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
@@ -158,9 +159,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()),
},
@@ -168,7 +169,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
)
})?;
@@ -186,11 +187,34 @@ impl DirectConnectorManagerData {
.await
.with_context(|| format!("failed to bind local socket for {}", remote_url))?,
);
let connector_ip = self
.peer_manager
.get_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::<Ipv6Addr>()
.with_context(|| {
format!(
"failed to parse public ipv6 address from stun info: {:?}",
self.peer_manager
.get_global_ctx()
.get_stun_info_collector()
.get_stun_info()
)
})?;
let connector_addr =
SocketAddr::new(IpAddr::V6(connector_ip), local_socket.local_addr()?.port());
// 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)
.remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url)
.await;
let udp_connector = UdpTunnelConnector::new(remote_url.clone());
@@ -205,14 +229,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),
@@ -303,7 +393,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;
};
@@ -457,7 +547,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<_>>();
@@ -575,6 +665,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")
@@ -675,6 +773,17 @@ 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)]
@@ -693,10 +802,53 @@ mod tests {
proto::peer_rpc::GetIpListResponse,
};
use super::TESTING;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use super::{TESTING, mapped_listener_port, resolve_mapped_listener_addrs};
#[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 direct_connector_mapped_listener() {
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;
@@ -715,11 +867,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();
@@ -736,6 +888,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(
+7
View File
@@ -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()),
+227 -51
View File
@@ -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>,
@@ -607,7 +720,7 @@ async fn check_udp_socket_local_addr(
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
// 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) {
@@ -615,8 +728,8 @@ async fn check_udp_socket_local_addr(
}
}
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 global_ctx.is_ip_local_ipv6(&ip) {
return Err(anyhow::anyhow!("local address is easytier-managed ipv6").into());
}
}
}
@@ -647,3 +760,66 @@ pub(crate) async fn try_connect_with_socket(
.await
.map_err(Error::from)
}
#[cfg(test)]
mod tests {
use super::{
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, should_create_public_listener,
should_retry_public_listener_selection,
};
#[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
));
}
}
+56 -29
View File
@@ -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);
}
}
+9 -1
View File
@@ -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!(
+245 -87
View File
@@ -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",
@@ -722,73 +756,100 @@ 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)
}
}
impl NetworkOptions {
fn can_merge(&self, cfg: &TomlConfigLoader, config_file_count: usize) -> bool {
fn can_merge(
&self,
cfg: &TomlConfigLoader,
source: ConfigFileSource,
explicit_config_file_count: usize,
config_dir_file_count: usize,
) -> bool {
if (*self) == NetworkOptions::default() {
return false;
}
if config_file_count == 1 {
if source == ConfigFileSource::CliConfigFile
&& explicit_config_file_count == 1
&& config_dir_file_count == 0
{
return true;
}
let Some(network_name) = &self.network_name else {
return false;
};
if cfg.get_network_identity().network_name == *network_name {
return true;
if source == ConfigFileSource::ConfigDir {
return cfg.get_network_identity().network_name == *network_name;
}
false
cfg.get_network_identity().network_name == *network_name
}
fn merge_into(&self, cfg: &TomlConfigLoader) -> anyhow::Result<()> {
@@ -826,6 +887,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());
@@ -858,32 +933,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() {
@@ -1074,7 +1124,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);
cfg.set_flags(f);
if !self.exit_nodes.is_empty() {
@@ -1104,6 +1157,12 @@ impl NetworkOptions {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfigFileSource {
CliConfigFile,
ConfigDir,
}
impl LoggingConfigLoader for &LoggingOptions {
fn get_console_logger_config(&self) -> ConsoleLoggerConfig {
ConsoleLoggerConfig {
@@ -1286,8 +1345,13 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
None
};
let explicit_config_file_count = cli.config_file.as_ref().map_or(0, |files| files.len());
let mut config_dir_file_count = 0;
let mut config_files = if let Some(v) = cli.config_file {
v.clone()
v.iter()
.cloned()
.map(|path| (path, ConfigFileSource::CliConfigFile))
.collect()
} else {
vec![]
};
@@ -1308,7 +1372,8 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
if ext != "toml" {
continue;
}
config_files.push(path);
config_dir_file_count += 1;
config_files.push((path, ConfigFileSource::ConfigDir));
}
}
let config_file_count = config_files.len();
@@ -1321,7 +1386,7 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
cli.network_options.network_name.is_some()
}
};
for config_file in config_files {
for (config_file, source) in config_files {
let (cfg, mut control) = load_config_from_file(
&config_file,
cli.config_dir.as_ref(),
@@ -1329,7 +1394,12 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
)
.await?;
if cli.network_options.can_merge(&cfg, config_file_count) {
if cli.network_options.can_merge(
&cfg,
source,
explicit_config_file_count,
config_dir_file_count,
) {
cli.network_options
.merge_into(&cfg)
.with_context(|| format!("failed to merge config from cli: {:?}", config_file))?;
@@ -1534,3 +1604,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
);
}
}
}
+1 -1
View File
@@ -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" 或空字符串)
+211 -7
View File
@@ -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?;
}
+2 -1
View File
@@ -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();
+7 -1
View File
@@ -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::{
@@ -611,6 +612,11 @@ impl QuicStreamReceiver {
}
_ = self.tasks.join_next(), if !self.tasks.is_empty() => {}
else => {
info!("quic stream receiver endpoint closed, exiting");
break;
}
}
}
}
@@ -657,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();
+4 -6
View File
@@ -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 {
+3 -4
View File
@@ -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 {
+4 -3
View File
@@ -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 {
+143 -5
View File
@@ -9,7 +9,6 @@ use std::time::Duration;
use anyhow::Context;
use cidr::{IpCidr, Ipv4Inet};
use futures::FutureExt;
use tokio::sync::{Mutex, Notify};
#[cfg(feature = "tun")]
@@ -20,7 +19,6 @@ 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;
@@ -63,6 +61,11 @@ use crate::utils::weak_upgrade;
use crate::vpn_portal::{self, VpnPortal};
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;
@@ -196,11 +199,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?;
@@ -210,7 +266,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));
@@ -225,9 +282,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(())
}
@@ -326,6 +404,10 @@ impl InstanceConfigPatcher {
global_ctx
.get_acl_filter()
.reload_rules(AclRuleBuilder::build(&global_ctx)?.as_ref());
weak_upgrade(&self.peer_manager)?
.get_route()
.refresh_acl_groups()
.await;
Ok(())
}
@@ -499,7 +581,7 @@ pub struct Instance {
#[cfg(feature = "socks5")]
socks5_server: Arc<Socks5Server>,
proxy_cidrs_monitor: Option<ScopedTask<()>>,
proxy_cidrs_monitor: Option<AbortOnDropHandle<()>>,
global_ctx: ArcGlobalCtx,
}
@@ -611,6 +693,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(
@@ -819,6 +907,7 @@ impl Instance {
}
pub async fn run(&mut self) -> Result<(), Error> {
self.prepare_public_ipv6_config().await?;
self.listener_manager
.lock()
.await
@@ -826,6 +915,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")]
{
@@ -1443,7 +1533,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]
@@ -1564,4 +1656,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());
}
}
+2
View File
@@ -3,6 +3,8 @@ pub mod instance;
pub mod listeners;
mod public_ipv6_provider;
pub mod proxy_cidrs_monitor;
#[cfg(feature = "tun")]
+3 -3
View File
@@ -3,8 +3,8 @@ use std::net::IpAddr;
use std::sync::{Arc, Weak};
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.
@@ -69,8 +69,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())
);
}
}
+194 -4
View File
@@ -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(())
}
+43 -5
View File
@@ -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,
@@ -357,6 +366,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);
}
@@ -413,6 +437,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,
+53 -17
View File
@@ -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;
}
@@ -821,6 +836,12 @@ impl NetworkConfig {
flags.encryption_algorithm = encryption_algorithm;
}
if let Some(acl) = self.acl.as_ref()
&& !acl.is_empty()
{
cfg.set_acl(Some(acl.clone()));
}
if let Some(data_compress_algo) = self.data_compress_algo {
if data_compress_algo < 1 {
flags.data_compress_algo = 1;
@@ -856,6 +877,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() {
@@ -953,12 +985,15 @@ 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.mtu = Some(flags.mtu as i32);
result.instance_recv_bps_limit =
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
result.enable_private_mode = Some(flags.private_mode);
result.acl = config.get_acl();
if flags.relay_network_whitelist == "*" {
result.enable_relay_network_whitelist = Some(false);
} else {
@@ -1217,6 +1252,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.mtu = rng.gen_range(1200..1500);
flags.private_mode = rng.gen_bool(0.3);
+78 -17
View File
@@ -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(),
})),
}
}
@@ -293,13 +292,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 +343,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 +394,55 @@ impl AclFilter {
}
}
}
#[cfg(test)]
mod tests {
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
sync::Arc,
};
use crate::{
common::acl_processor::PacketInfo,
proto::acl::{ChainType, Protocol},
};
use super::AclFilter;
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);
}
}
+148 -39
View File
@@ -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));
}
}
+12 -14
View File
@@ -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(
+2
View File
@@ -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;
@@ -20,6 +21,7 @@ pub mod foreign_network_client;
pub mod foreign_network_manager;
pub mod encrypt;
pub(crate) mod secure_datagram;
pub mod peer_task;
+7 -10
View File
@@ -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 {
+15 -16
View File
@@ -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;
+98 -53
View File
@@ -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
+14 -10
View File
@@ -5,7 +5,7 @@ use crate::{
proto::{
common::Void,
peer_rpc::{
DirectConnectorRpc, GetIpListRequest, GetIpListResponse, SendV6HolePunchPacketRequest,
DirectConnectorRpc, GetIpListRequest, GetIpListResponse, SendUdpHolePunchPacketRequest,
},
rpc_types::{self, controller::BaseController},
},
@@ -41,6 +41,10 @@ impl DirectConnectorRpc for DirectConnectorManagerRpcServer {
let et_ipv6: crate::proto::common::Ipv6Addr = et_ipv6.address().into();
ret.interface_ipv6s.retain(|x| *x != et_ipv6);
}
if let Some(public_ipv6) = self.global_ctx.get_public_ipv6_lease() {
let public_ipv6: crate::proto::common::Ipv6Addr = public_ipv6.address().into();
ret.interface_ipv6s.retain(|x| *x != public_ipv6);
}
tracing::trace!(
"get_ip_list: public_ipv4: {:?}, public_ipv6: {:?}, listeners: {:?}",
ret.public_ipv4,
@@ -50,29 +54,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())
+46 -788
View File
@@ -1,26 +1,14 @@
use std::{
sync::{
Arc, Mutex, RwLock,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
use std::sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
};
use atomic_shim::AtomicU64;
use crate::{
common::PeerId,
peers::encrypt::{Encryptor, create_encryptor},
tunnel::packet_def::{StandardAeadTail, ZCPacket},
};
use anyhow::anyhow;
use dashmap::DashMap;
use hmac::{Hmac, Mac as _};
use rand::RngCore as _;
use sha2::Sha256;
use zerocopy::FromBytes;
type HmacSha256 = Hmac<Sha256>;
use super::secure_datagram::{SecureDatagramDirection, SecureDatagramSession};
use crate::{common::PeerId, tunnel::packet_def::ZCPacket};
pub struct UpsertResponderSessionReturn {
pub session: Arc<PeerSession>,
pub action: PeerSessionAction,
@@ -87,9 +75,6 @@ impl PeerSessionStore {
self.sessions.insert(key, session);
}
/// Remove sessions that are no longer referenced by any PeerConn or RelayPeerMap.
/// A session with strong_count == 1 means only the store holds it — no active
/// connection is using it, so it can be safely cleaned up.
pub fn evict_unused_sessions(&self) {
self.sessions
.retain(|_key, session| Arc::strong_count(session) > 1);
@@ -188,7 +173,6 @@ impl PeerSessionStore {
}
PeerSessionAction::Sync | PeerSessionAction::Create => {
let root_key = root_key_32.ok_or_else(|| anyhow!("missing root_key"))?;
// If the existing session is invalidated, remove it so we create a fresh one
if let Some(existing) = self.sessions.get(key)
&& !existing.is_valid()
{
@@ -224,253 +208,25 @@ impl PeerSessionStore {
}
}
#[derive(Clone, Default)]
struct EpochKeySlot {
epoch: u32,
generation: u32,
valid: bool,
send_cipher: Option<Arc<dyn Encryptor>>,
recv_cipher: Option<Arc<dyn Encryptor>>,
}
impl std::fmt::Debug for EpochKeySlot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EpochKeySlot")
.field("epoch", &self.epoch)
.field("generation", &self.generation)
.field("valid", &self.valid)
.finish()
}
}
impl EpochKeySlot {
fn get_encryptor(&self, is_send: bool) -> Arc<dyn Encryptor> {
if is_send {
self.send_cipher.as_ref().unwrap().clone()
} else {
self.recv_cipher.as_ref().unwrap().clone()
}
}
}
#[derive(Debug, Clone, Copy, Default)]
struct ReplayWindow256 {
max_seq: u64,
bitmap: [u8; 32],
valid: bool,
}
impl ReplayWindow256 {
fn clear(&mut self) {
self.max_seq = 0;
self.bitmap.fill(0);
self.valid = false;
}
fn test_bit(&self, idx: usize) -> bool {
let byte = idx / 8;
let bit = idx % 8;
(self.bitmap[byte] >> bit) & 1 == 1
}
fn set_bit(&mut self, idx: usize) {
let byte = idx / 8;
let bit = idx % 8;
self.bitmap[byte] |= 1u8 << bit;
}
fn shift_right(&mut self, shift: usize) {
if shift == 0 {
return;
}
let total_bits = 256usize;
if shift >= total_bits {
self.bitmap.fill(0);
return;
}
let byte_shift = shift / 8;
let bit_shift = shift % 8;
if byte_shift > 0 {
for i in (0..self.bitmap.len()).rev() {
self.bitmap[i] = if i >= byte_shift {
self.bitmap[i - byte_shift]
} else {
0
};
}
}
if bit_shift > 0 {
let mut carry = 0u8;
for b in self.bitmap.iter_mut() {
let new_carry = *b >> (8 - bit_shift);
*b = (*b << bit_shift) | carry;
carry = new_carry;
}
}
}
fn accept(&mut self, seq: u64) -> bool {
if !self.valid {
self.valid = true;
self.max_seq = seq;
self.set_bit(0);
return true;
}
if seq > self.max_seq {
let shift = (seq - self.max_seq) as usize;
self.shift_right(shift);
self.max_seq = seq;
self.set_bit(0);
return true;
}
let delta = (self.max_seq - seq) as usize;
if delta >= 256 {
return false;
}
if self.test_bit(delta) {
return false;
}
self.set_bit(delta);
true
}
}
#[derive(Debug, Clone, Copy, Default)]
struct EpochRxSlot {
epoch: u32,
window: ReplayWindow256,
last_rx_ms: u64,
valid: bool,
}
impl EpochRxSlot {
fn clear(&mut self) {
self.epoch = 0;
self.window.clear();
self.last_rx_ms = 0;
self.valid = false;
}
}
#[derive(Debug, Clone, Copy, Default)]
struct SyncRxGrace {
slots: [[EpochRxSlot; 2]; 2],
expires_at_ms: u64,
valid: bool,
}
impl SyncRxGrace {
fn clear(&mut self) {
self.slots = [[EpochRxSlot::default(), EpochRxSlot::default()]; 2];
self.expires_at_ms = 0;
self.valid = false;
}
fn refresh(&mut self, slots: [[EpochRxSlot; 2]; 2], expires_at_ms: u64) {
self.slots = slots;
self.expires_at_ms = expires_at_ms;
self.valid = true;
}
fn maybe_expire(&mut self, now_ms: u64) {
if self.valid && now_ms >= self.expires_at_ms {
self.clear();
}
}
}
pub struct PeerSession {
peer_id: PeerId,
root_key: RwLock<[u8; 32]>,
session_generation: AtomicU32,
peer_static_pubkey: RwLock<Option<[u8; 32]>>,
send_epoch: AtomicU32,
send_seq: [AtomicU64; 2],
send_epoch_started_ms: AtomicU64,
send_packets_since_epoch: AtomicU64,
rx_slots: Mutex<[[EpochRxSlot; 2]; 2]>,
key_cache: Mutex<[[EpochKeySlot; 2]; 2]>,
sync_rx_grace: Mutex<SyncRxGrace>,
sync_rx_grace_expires_at_ms: AtomicU64,
send_cipher_algorithm: String,
recv_cipher_algorithm: String,
/// Set to true when the session is detected as corrupted (persistent decrypt failures).
/// Holders of Arc<PeerSession> can check this to know the session should be discarded.
datagram: SecureDatagramSession,
invalidated: AtomicBool,
/// Consecutive decrypt failure counter. Auto-invalidates when threshold is reached.
decrypt_fail_count: AtomicU32,
}
impl std::fmt::Debug for PeerSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PeerSession")
.field("peer_id", &self.peer_id)
.field("root_key", &self.root_key)
.field("session_generation", &self.session_generation)
.field("peer_static_pubkey", &self.peer_static_pubkey)
.field("send_epoch", &self.send_epoch)
.field("send_seq", &self.send_seq)
.field("send_epoch_started_ms", &self.send_epoch_started_ms)
.field("send_packets_since_epoch", &self.send_packets_since_epoch)
.field("rx_slots", &self.rx_slots)
.field("key_cache", &self.key_cache)
.field("sync_rx_grace", &self.sync_rx_grace)
.field(
"sync_rx_grace_expires_at_ms",
&self.sync_rx_grace_expires_at_ms,
)
.field("send_cipher_algorithm", &self.send_cipher_algorithm)
.field("recv_cipher_algorithm", &self.recv_cipher_algorithm)
.field("datagram", &self.datagram)
.finish()
}
}
impl PeerSession {
/// Idle-eviction timeout for receive slots, in milliseconds.
///
/// If no packets are received for this period (~30 seconds), the
/// corresponding RX slot is considered idle and may be cleared/reused.
/// This helps reclaim state for dead peers or paths while still tolerating
/// short network stalls. Environments with very bursty or high-latency
/// traffic may want to increase this value; low-latency or tightly
/// resource-constrained deployments may lower it.
const EVICT_IDLE_AFTER_MS: u64 = 30_000;
/// Keep the pre-sync receive windows alive briefly so in-flight packets
/// from the previous epochs are still accepted after a shared session is
/// synced in place by another connection.
const SYNC_RX_GRACE_AFTER_MS: u64 = 5_000;
/// Maximum number of packets to send in a single epoch before forcing
/// a key/epoch rotation.
///
/// This bounds the amount of traffic protected under a single set of
/// derived keys, which is a common best practice for long-lived secure
/// channels. The current value (~1 million packets) is a conservative
/// default chosen to balance security (more frequent rotation) and
/// performance (avoiding excessive rekeying). Deployments with very high
/// or very low packet rates may tune this threshold accordingly.
const ROTATE_AFTER_PACKETS: u64 = 1_000_000;
/// Maximum wall-clock lifetime of a send epoch, in milliseconds.
///
/// Even if the packet-based limit is not reached, epochs are rotated
/// after this duration (~10 minutes) to avoid long-lived keys and keep
/// replay windows bounded in time. This also limits the impact of a
/// compromised key. Installations that prioritize lower overhead over
/// more aggressive key rotation may increase this value; those with
/// stricter security requirements may decrease it.
const ROTATE_AFTER_MS: u64 = 10 * 60 * 1000;
const MAX_ACCEPTED_RX_EPOCH_AHEAD: u32 = 3;
const DECRYPT_FAIL_THRESHOLD: u32 = 10;
const SYNC_RX_GRACE_AFTER_MS: u64 = SecureDatagramSession::SYNC_RX_GRACE_AFTER_MS;
pub fn new(
peer_id: PeerId,
@@ -481,32 +237,17 @@ impl PeerSession {
recv_cipher_algorithm: String,
peer_static_pubkey: Option<[u8; 32]>,
) -> Self {
let rx_slots = [
[EpochRxSlot::default(), EpochRxSlot::default()],
[EpochRxSlot::default(), EpochRxSlot::default()],
];
let key_cache = [
[EpochKeySlot::default(), EpochKeySlot::default()],
[EpochKeySlot::default(), EpochKeySlot::default()],
];
let now_ms = now_ms();
Self {
peer_id,
root_key: RwLock::new(root_key),
session_generation: AtomicU32::new(session_generation),
peer_static_pubkey: RwLock::new(peer_static_pubkey),
send_epoch: AtomicU32::new(initial_epoch),
send_seq: [AtomicU64::new(0), AtomicU64::new(0)],
send_epoch_started_ms: AtomicU64::new(now_ms),
send_packets_since_epoch: AtomicU64::new(0),
rx_slots: Mutex::new(rx_slots),
key_cache: Mutex::new(key_cache),
sync_rx_grace: Mutex::new(SyncRxGrace::default()),
sync_rx_grace_expires_at_ms: AtomicU64::new(0),
send_cipher_algorithm,
recv_cipher_algorithm,
datagram: SecureDatagramSession::new(
root_key,
session_generation,
initial_epoch,
send_cipher_algorithm,
recv_cipher_algorithm,
),
invalidated: AtomicBool::new(false),
decrypt_fail_count: AtomicU32::new(0),
}
}
@@ -514,44 +255,29 @@ impl PeerSession {
self.peer_id
}
/// Mark this session as invalid. All holders of Arc<PeerSession> will see this.
pub fn invalidate(&self) {
self.invalidated.store(true, Ordering::Relaxed);
self.datagram.invalidate();
}
pub fn is_valid(&self) -> bool {
!self.invalidated.load(Ordering::Relaxed)
!self.invalidated.load(Ordering::Relaxed) && self.datagram.is_valid()
}
pub fn session_generation(&self) -> u32 {
self.session_generation.load(Ordering::Relaxed)
self.datagram.session_generation()
}
pub fn root_key(&self) -> [u8; 32] {
*self.root_key.read().unwrap()
self.datagram.root_key()
}
pub fn new_root_key() -> [u8; 32] {
let mut out = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut out);
out
SecureDatagramSession::new_root_key()
}
pub fn next_sync_epoch(&self) -> u32 {
let send_epoch = self.send_epoch.load(Ordering::Relaxed);
let rx = self.rx_slots.lock().unwrap();
let mut max_epoch = send_epoch;
for dir in 0..2 {
let cur = rx[dir][0];
if cur.valid {
max_epoch = max_epoch.max(cur.epoch);
}
let prev = rx[dir][1];
if prev.valid {
max_epoch = max_epoch.max(prev.epoch);
}
}
max_epoch.wrapping_add(1)
self.datagram.next_sync_epoch()
}
pub fn check_encrypt_algo_same(
@@ -559,12 +285,8 @@ impl PeerSession {
send_algorithm: &str,
recv_algorithm: &str,
) -> Result<(), anyhow::Error> {
if self.send_cipher_algorithm != send_algorithm
|| self.recv_cipher_algorithm != recv_algorithm
{
return Err(anyhow!("encrypt algorithm not same"));
}
Ok(())
self.datagram
.check_encrypt_algo_same(send_algorithm, recv_algorithm)
}
pub fn check_or_set_peer_static_pubkey(
@@ -592,277 +314,25 @@ impl PeerSession {
initial_epoch: u32,
preserve_rx_grace: bool,
) {
let old_root_key = self.root_key();
let can_preserve_rx_grace = preserve_rx_grace && old_root_key == root_key;
{
let mut g = self.root_key.write().unwrap();
*g = root_key;
}
self.session_generation
.store(session_generation, Ordering::Relaxed);
self.send_epoch.store(initial_epoch, Ordering::Relaxed);
self.send_seq[0].store(0, Ordering::Relaxed);
self.send_seq[1].store(0, Ordering::Relaxed);
self.send_epoch_started_ms
.store(now_ms(), Ordering::Relaxed);
self.send_packets_since_epoch.store(0, Ordering::Relaxed);
{
let mut rx = self.rx_slots.lock().unwrap();
let mut sync_rx_grace = self.sync_rx_grace.lock().unwrap();
if can_preserve_rx_grace {
let expires_at_ms = now_ms().saturating_add(Self::SYNC_RX_GRACE_AFTER_MS);
sync_rx_grace.refresh(*rx, expires_at_ms);
self.sync_rx_grace_expires_at_ms
.store(expires_at_ms, Ordering::Relaxed);
} else {
sync_rx_grace.clear();
self.sync_rx_grace_expires_at_ms.store(0, Ordering::Relaxed);
}
for dir in 0..2 {
rx[dir][0].clear();
rx[dir][1].clear();
}
}
self.key_cache
.lock()
.unwrap()
.fill([EpochKeySlot::default(), EpochKeySlot::default()]);
self.datagram.sync_root_key(
root_key,
session_generation,
initial_epoch,
preserve_rx_grace,
);
}
pub fn dir_for_sender(sender_peer_id: PeerId, receiver_peer_id: PeerId) -> usize {
pub fn dir_for_sender(
sender_peer_id: PeerId,
receiver_peer_id: PeerId,
) -> SecureDatagramDirection {
if sender_peer_id < receiver_peer_id {
0
SecureDatagramDirection::AToB
} else {
1
SecureDatagramDirection::BToA
}
}
fn hkdf_traffic_key(&self, epoch: u32, dir: usize) -> [u8; 32] {
let root_key = self.root_key();
let salt = [0u8; 32];
let mut extract = HmacSha256::new_from_slice(&salt).unwrap();
extract.update(&root_key);
let prk = extract.finalize().into_bytes();
let mut info = Vec::with_capacity(9 + 4 + 1);
info.extend_from_slice(b"et-traffic");
info.extend_from_slice(&epoch.to_be_bytes());
info.push(dir as u8);
let mut expand = HmacSha256::new_from_slice(&prk).unwrap();
expand.update(&info);
expand.update(&[1u8]);
let okm = expand.finalize().into_bytes();
let mut key = [0u8; 32];
key.copy_from_slice(&okm[..32]);
key
}
fn get_or_create_encryptor(
&self,
epoch: u32,
dir: usize,
generation: u32,
is_send: bool,
) -> Arc<dyn Encryptor> {
let mut guard = self.key_cache.lock().unwrap();
for slot in guard[dir].iter_mut() {
if slot.valid && slot.epoch == epoch && slot.generation == generation {
return slot.get_encryptor(is_send);
}
}
let key = self.hkdf_traffic_key(epoch, dir);
let mut key_128 = [0u8; 16];
key_128.copy_from_slice(&key[..16]);
let slot = EpochKeySlot {
epoch,
generation,
valid: true,
send_cipher: Some(create_encryptor(&self.send_cipher_algorithm, key_128, key)),
recv_cipher: Some(create_encryptor(&self.recv_cipher_algorithm, key_128, key)),
};
let ret = slot.get_encryptor(is_send);
if !guard[dir][0].valid || guard[dir][0].epoch == epoch {
guard[dir][0] = slot;
} else {
guard[dir][1] = slot;
}
ret
}
fn maybe_rotate_epoch(&self, now_ms: u64) {
let packets = self
.send_packets_since_epoch
.fetch_add(1, Ordering::Relaxed)
+ 1;
let started = self.send_epoch_started_ms.load(Ordering::Relaxed);
if packets < Self::ROTATE_AFTER_PACKETS
&& now_ms.saturating_sub(started) < Self::ROTATE_AFTER_MS
{
return;
}
let cur = self.send_epoch.load(Ordering::Relaxed);
let next = cur.wrapping_add(1);
if self
.send_epoch
.compare_exchange(cur, next, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
self.send_epoch_started_ms.store(now_ms, Ordering::Relaxed);
self.send_packets_since_epoch.store(0, Ordering::Relaxed);
}
}
fn next_nonce(&self, dir: usize) -> (u32, u64, [u8; 12]) {
let now_ms = now_ms();
self.maybe_rotate_epoch(now_ms);
let epoch = self.send_epoch.load(Ordering::Relaxed);
let seq = self.send_seq[dir].fetch_add(1, Ordering::Relaxed);
let mut nonce = [0u8; 12];
nonce[..4].copy_from_slice(&epoch.to_be_bytes());
nonce[4..].copy_from_slice(&seq.to_be_bytes());
(epoch, seq, nonce)
}
fn parse_tail(payload: &[u8]) -> Option<[u8; 12]> {
let tail = StandardAeadTail::ref_from_suffix(payload)?;
Some(tail.nonce)
}
fn evict_old_rx_slots(rx: &mut [[EpochRxSlot; 2]; 2], now_ms: u64) {
for dir_slots in rx.iter_mut() {
for slot in dir_slots.iter_mut() {
if !slot.valid {
continue;
}
let last = slot.last_rx_ms;
if last != 0 && now_ms.saturating_sub(last) > Self::EVICT_IDLE_AFTER_MS {
slot.clear();
}
}
}
}
fn epoch_in_slots(slots: &[EpochRxSlot; 2], epoch: u32) -> bool {
slots[0].valid && slots[0].epoch == epoch || slots[1].valid && slots[1].epoch == epoch
}
fn sync_rx_grace_active(&self, now_ms: u64) -> bool {
let expires_at_ms = self.sync_rx_grace_expires_at_ms.load(Ordering::Relaxed);
if expires_at_ms == 0 {
return false;
}
if now_ms < expires_at_ms {
return true;
}
self.sync_rx_grace_expires_at_ms.store(0, Ordering::Relaxed);
false
}
fn check_replay(&self, epoch: u32, seq: u64, dir: usize, now_ms: u64) -> bool {
let mut rx = self.rx_slots.lock().unwrap();
Self::evict_old_rx_slots(&mut rx, now_ms);
let mut sync_rx_grace = if self.sync_rx_grace_active(now_ms) {
let mut sync_rx_grace = self.sync_rx_grace.lock().unwrap();
sync_rx_grace.maybe_expire(now_ms);
if sync_rx_grace.valid {
Self::evict_old_rx_slots(&mut sync_rx_grace.slots, now_ms);
Some(sync_rx_grace)
} else {
self.sync_rx_grace_expires_at_ms.store(0, Ordering::Relaxed);
None
}
} else {
None
};
let send_epoch = self.send_epoch.load(Ordering::Relaxed);
{
let mut key_cache = self.key_cache.lock().unwrap();
for d in 0..2 {
for s in 0..2 {
if !key_cache[d][s].valid {
continue;
}
let e = key_cache[d][s].epoch;
let allowed = e == send_epoch
|| rx[d][0].valid && rx[d][0].epoch == e
|| rx[d][1].valid && rx[d][1].epoch == e
|| sync_rx_grace
.as_ref()
.is_some_and(|g| Self::epoch_in_slots(&g.slots[d], e));
if !allowed {
key_cache[d][s].valid = false;
}
}
}
}
if sync_rx_grace
.as_ref()
.is_some_and(|g| Self::epoch_in_slots(&g.slots[dir], epoch))
{
for slot in sync_rx_grace.as_mut().unwrap().slots[dir].iter_mut() {
if slot.valid && slot.epoch == epoch {
slot.last_rx_ms = now_ms;
return slot.window.accept(seq);
}
}
}
if !rx[dir][0].valid {
rx[dir][0] = EpochRxSlot {
epoch,
window: ReplayWindow256::default(),
last_rx_ms: now_ms,
valid: true,
};
}
if rx[dir][0].valid && epoch == rx[dir][0].epoch {
rx[dir][0].last_rx_ms = now_ms;
return rx[dir][0].window.accept(seq);
}
if rx[dir][1].valid && epoch == rx[dir][1].epoch {
rx[dir][1].last_rx_ms = now_ms;
return rx[dir][1].window.accept(seq);
}
if rx[dir][0].valid && epoch > rx[dir][0].epoch {
let mut baseline_epoch = send_epoch;
if rx[dir][0].valid {
baseline_epoch = baseline_epoch.max(rx[dir][0].epoch);
}
if rx[dir][1].valid {
baseline_epoch = baseline_epoch.max(rx[dir][1].epoch);
}
let max_allowed_epoch =
baseline_epoch.saturating_add(Self::MAX_ACCEPTED_RX_EPOCH_AHEAD);
if epoch > max_allowed_epoch {
return false;
}
rx[dir][1] = rx[dir][0];
rx[dir][0] = EpochRxSlot {
epoch,
window: ReplayWindow256::default(),
last_rx_ms: now_ms,
valid: true,
};
return rx[dir][0].window.accept(seq);
}
false
}
pub fn encrypt_payload(
&self,
sender_peer_id: PeerId,
@@ -872,19 +342,8 @@ impl PeerSession {
if !self.is_valid() {
return Err(anyhow!("session invalidated"));
}
let dir = Self::dir_for_sender(sender_peer_id, receiver_peer_id);
let (epoch, _seq, nonce_bytes) = self.next_nonce(dir);
let encryptor = self.get_or_create_encryptor(epoch, dir, self.session_generation(), true);
if let Err(e) = encryptor.encrypt_with_nonce(pkt, Some(nonce_bytes.as_slice())) {
tracing::warn!(
peer_id = ?self.peer_id,
?e,
"session encrypt failed, invalidating"
);
self.invalidate();
return Err(e.into());
}
Ok(())
self.datagram
.encrypt_payload(Self::dir_for_sender(sender_peer_id, receiver_peer_id), pkt)
}
pub fn decrypt_payload(
@@ -896,47 +355,13 @@ impl PeerSession {
if !self.is_valid() {
return Err(anyhow!("session invalidated"));
}
let dir = Self::dir_for_sender(sender_peer_id, receiver_peer_id);
let nonce_bytes =
Self::parse_tail(ciphertext_with_tail.payload()).ok_or_else(|| anyhow!("no tail"))?;
let epoch = u32::from_be_bytes(nonce_bytes[..4].try_into().unwrap());
let seq = u64::from_be_bytes(nonce_bytes[4..].try_into().unwrap());
let now_ms = now_ms();
if !self.check_replay(epoch, seq, dir, now_ms) {
return Err(anyhow!(
"replay rejected, sender_peer_id: {:?}, receiver_peer_id: {:?}",
sender_peer_id,
receiver_peer_id
));
}
let encryptor = self.get_or_create_encryptor(epoch, dir, self.session_generation(), false);
if let Err(e) = encryptor.decrypt(ciphertext_with_tail) {
let count = self.decrypt_fail_count.fetch_add(1, Ordering::Relaxed) + 1;
if count >= Self::DECRYPT_FAIL_THRESHOLD {
self.invalidate();
tracing::warn!(
peer_id = ?self.peer_id,
count,
"session auto-invalidated after consecutive decrypt failures"
);
}
return Err(e.into());
}
self.decrypt_fail_count.store(0, Ordering::Relaxed);
Ok(())
self.datagram.decrypt_payload(
Self::dir_for_sender(sender_peer_id, receiver_peer_id),
ciphertext_with_tail,
)
}
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
@@ -984,177 +409,10 @@ mod tests {
}
#[test]
fn replay_rejects_far_future_epoch_without_poisoning_window() {
let peer_id: PeerId = 10;
let root_key = PeerSession::new_root_key();
let generation = 1u32;
let initial_epoch = 0u32;
let s = PeerSession::new(
peer_id,
root_key,
generation,
initial_epoch,
"aes-256-gcm".to_string(),
"aes-256-gcm".to_string(),
None,
);
let now = now_ms();
assert!(s.check_replay(0, 1, 0, now));
assert!(s.check_replay(0, 2, 0, now));
assert!(!s.check_replay(1000, 1, 0, now));
assert!(s.check_replay(1, 1, 0, now + 1));
assert!(s.check_replay(1, 2, 0, now + 2));
}
#[test]
fn replay_window_shift_preserves_bits() {
let mut w = ReplayWindow256::default();
// Accept seqs 0..10
for i in 0..10u64 {
assert!(w.accept(i), "seq {i} should be accepted");
}
assert_eq!(w.max_seq, 9);
// All seqs 0..10 should be marked as seen (replay)
for i in 0..10u64 {
assert!(!w.accept(i), "seq {i} should be rejected as replay");
}
// Seq 10 should still be accepted
assert!(w.accept(10));
}
#[test]
fn replay_window_out_of_order_within_window() {
let mut w = ReplayWindow256::default();
// Accept even seqs 0,2,4,...,20
for i in (0..=20u64).step_by(2) {
assert!(w.accept(i), "seq {i} should be accepted");
}
// Now accept odd seqs 1,3,5,...,19 (out of order, within window)
for i in (1..=19u64).step_by(2) {
assert!(w.accept(i), "seq {i} should be accepted (out of order)");
}
// All seqs 0..=20 should now be marked as seen
for i in 0..=20u64 {
assert!(!w.accept(i), "seq {i} should be rejected as replay");
}
}
#[test]
fn sync_root_key_allows_any_epoch_from_remote() {
// After sync_root_key, the remote peer may still be sending at an
// old epoch. The receiver should accept those packets.
let peer_id: PeerId = 10;
let root_key = PeerSession::new_root_key();
let s = PeerSession::new(
peer_id,
root_key,
1,
0,
"aes-256-gcm".to_string(),
"aes-256-gcm".to_string(),
None,
);
// Simulate receiving some packets at epoch 0
let now = now_ms();
assert!(s.check_replay(0, 0, 0, now));
assert!(s.check_replay(0, 1, 0, now));
// Sync with initial_epoch=2 (simulating a Sync action)
s.sync_root_key(root_key, 2, 2, true);
// Remote peer is still sending at epoch 0 — should be accepted
// (rx_slots were cleared, so the first packet establishes the epoch)
assert!(
s.check_replay(0, 10, 0, now + 1),
"packets at old epoch should be accepted after sync"
);
}
#[test]
fn sync_root_key_keeps_previous_epochs_during_grace_window() {
let peer_id: PeerId = 10;
let root_key = PeerSession::new_root_key();
let s = PeerSession::new(
peer_id,
root_key,
1,
0,
"aes-256-gcm".to_string(),
"aes-256-gcm".to_string(),
None,
);
let now = now_ms();
assert!(s.check_replay(0, 0, 0, now));
assert!(s.check_replay(1, 0, 0, now + 1));
s.sync_root_key(root_key, 2, 2, true);
// The first packet after sync may already use the new epoch.
assert!(s.check_replay(2, 0, 0, now + 2));
// Older in-flight packets from pre-sync epochs should still be accepted
// during the grace period, regardless of arrival order.
assert!(s.check_replay(1, 1, 0, now + 3));
assert!(s.check_replay(0, 1, 0, now + 4));
}
#[test]
fn sync_root_key_expires_previous_epochs_after_grace_window() {
let peer_id: PeerId = 10;
let root_key = PeerSession::new_root_key();
let s = PeerSession::new(
peer_id,
root_key,
1,
0,
"aes-256-gcm".to_string(),
"aes-256-gcm".to_string(),
None,
);
let now = now_ms();
assert!(s.check_replay(0, 0, 0, now));
assert!(s.check_replay(1, 0, 0, now + 1));
s.sync_root_key(root_key, 2, 2, true);
assert!(s.check_replay(2, 0, 0, now + 2));
assert!(
!s.check_replay(0, 1, 0, now + PeerSession::SYNC_RX_GRACE_AFTER_MS + 3),
"old epochs should stop being accepted once the sync grace window expires"
);
}
#[test]
fn sync_root_key_does_not_preserve_previous_epochs_when_root_key_changes() {
let peer_id: PeerId = 10;
let root_key = PeerSession::new_root_key();
let s = PeerSession::new(
peer_id,
root_key,
1,
0,
"aes-256-gcm".to_string(),
"aes-256-gcm".to_string(),
None,
);
let now = now_ms();
assert!(s.check_replay(0, 0, 0, now));
assert!(s.check_replay(1, 0, 0, now + 1));
s.sync_root_key(PeerSession::new_root_key(), 2, 2, true);
assert!(s.check_replay(2, 0, 0, now + 2));
assert!(
!s.check_replay(1, 1, 0, now + 3),
"old epochs should not be preserved when sync replaces the root key"
fn sync_root_key_preserves_generic_grace_window_constant() {
assert_eq!(
PeerSession::SYNC_RX_GRACE_AFTER_MS,
SecureDatagramSession::SYNC_RX_GRACE_AFTER_MS
);
}
}
+9 -8
View File
@@ -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
+33 -3
View File
@@ -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
}
@@ -141,6 +161,8 @@ pub trait Route {
fn get_peer_groups(&self, peer_id: PeerId) -> Arc<Vec<String>>;
async fn refresh_acl_groups(&self) {}
async fn get_peer_groups_by_ip(&self, ip: &std::net::IpAddr) -> Arc<Vec<String>> {
match self.get_peer_id_by_ip(ip).await {
Some(peer_id) => self.get_peer_groups(peer_id),
@@ -192,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")
}
+15 -4
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+102 -1
View File
@@ -8,7 +8,7 @@ use crate::{
PeerId,
error::Error,
global_ctx::{
NetworkIdentity,
NetworkIdentity, TrustedKeySource,
tests::{get_mock_global_ctx, get_mock_global_ctx_with_network},
},
stats_manager::{LabelSet, LabelType, MetricName},
@@ -1315,6 +1315,107 @@ async fn credential_node_group_assignment() {
.await;
}
#[tokio::test]
async fn credential_node_connected_via_admin_b_trusts_admin_a_groups() {
use crate::proto::acl::{Acl, AclV1, GroupIdentity, GroupInfo};
let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
let group_declares = vec![GroupIdentity {
group_name: "platform-admin".to_string(),
group_secret: "platform-admin-secret".to_string(),
}];
admin_a.get_global_ctx().config.set_acl(Some(Acl {
acl_v1: Some(AclV1 {
group: Some(GroupInfo {
declares: group_declares.clone(),
members: vec!["platform-admin".to_string()],
}),
..Default::default()
}),
}));
admin_b.get_global_ctx().config.set_acl(Some(Acl {
acl_v1: Some(AclV1 {
group: Some(GroupInfo {
declares: group_declares,
members: vec![],
}),
..Default::default()
}),
}));
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
wait_route_appear(admin_a.clone(), admin_b.clone())
.await
.unwrap();
let (_cred_id, cred_secret) = admin_a
.get_global_ctx()
.get_credential_manager()
.generate_credential(vec![], false, vec![], std::time::Duration::from_secs(3600));
admin_a
.get_global_ctx()
.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
let privkey_bytes: [u8; 32] = base64::engine::general_purpose::STANDARD
.decode(&cred_secret)
.unwrap()
.try_into()
.unwrap();
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
let credential_pubkey = x25519_dalek::PublicKey::from(&private).as_bytes().to_vec();
wait_for_condition(
|| {
let admin_b = admin_b.clone();
let credential_pubkey = credential_pubkey.clone();
async move {
admin_b.get_global_ctx().is_pubkey_trusted_with_source(
&credential_pubkey,
"net1",
TrustedKeySource::OspfCredential,
)
}
},
Duration::from_secs(10),
)
.await;
let cred_c = create_mock_peer_manager_credential("net1".to_string(), &private).await;
connect_peer_manager(cred_c.clone(), admin_b.clone()).await;
let admin_a_id = admin_a.my_peer_id();
wait_for_condition(
|| {
let cred_c = cred_c.clone();
async move {
cred_c
.list_routes()
.await
.iter()
.any(|r| r.peer_id == admin_a_id)
}
},
Duration::from_secs(10),
)
.await;
wait_for_condition(
|| {
let cred_c = cred_c.clone();
async move {
cred_c
.get_route()
.get_peer_groups(admin_a_id)
.contains(&"platform-admin".to_string())
}
},
Duration::from_secs(10),
)
.await;
}
/// Minimal test: two secure peers connect and discover each other's route.
#[tokio::test]
async fn two_secure_peers_route_appear() {
+20
View File
@@ -2,6 +2,26 @@ use std::fmt::Display;
include!(concat!(env!("OUT_DIR"), "/acl.rs"));
impl Acl {
pub fn is_empty(&self) -> bool {
self.acl_v1.as_ref().map(|v1| v1.is_empty()).unwrap_or(true)
}
}
impl AclV1 {
pub fn is_empty(&self) -> bool {
let has_chains = !self.chains.is_empty();
let has_groups = self.group.as_ref().map(|g| !g.is_empty()).unwrap_or(false);
!has_chains && !has_groups
}
}
impl GroupInfo {
pub fn is_empty(&self) -> bool {
self.declares.is_empty() && self.members.is_empty()
}
}
impl Display for ConnTrackEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let src = self
+3
View File
@@ -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 {
+23
View File
@@ -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 {
+18 -2
View File
@@ -3,6 +3,7 @@ syntax = "proto3";
import "common.proto";
import "peer_rpc.proto";
import "api_instance.proto";
import "acl.proto";
package api.manage;
@@ -12,6 +13,12 @@ enum NetworkingMethod {
Standalone = 2;
}
enum ConfigSource {
ConfigSourceUnspecified = 0;
ConfigSourceUser = 1;
ConfigSourceWebhook = 2;
}
message NetworkConfig {
optional string instance_id = 1;
@@ -83,11 +90,15 @@ message NetworkConfig {
optional bool disable_tcp_hole_punching = 54;
common.SecureModeConfig secure_mode = 55;
reserved 56;
optional acl.Acl acl = 56;
optional string credential_file = 57;
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 {
@@ -130,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; }
@@ -140,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; }
@@ -166,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; }
+2
View File
@@ -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 {
+47 -2
View File
@@ -12,6 +12,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 {
@@ -49,6 +50,9 @@ message RoutePeerInfo {
repeated TrustedCredentialPubkeyProof trusted_credential_pubkeys = 19;
bytes dns = 20;
optional common.Ipv6Inet ipv6_public_addr_prefix = 22;
optional common.Ipv6Inet ipv6_public_addr_lease = 24;
}
message PeerIdVersion {
@@ -135,6 +139,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 {
@@ -145,18 +189,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 {
+52 -4
View File
@@ -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 -1
View File
@@ -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},
};
+2 -3
View File
@@ -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,
};

Some files were not shown because too many files have changed in this diff Show More