diff --git a/CONTEXT.md b/CONTEXT.md index 350566bc..83652d1a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -21,3 +21,10 @@ operation transition. Host capability operations use a separate seam. They turn Host readiness into Rust task wakeups and do not share the caller-to-core broker state machine. + +## Compact compatibility Host + +A compact compatibility Host retains accepted values in the authoritative TOML +model for management readback, while the shared host-aware normalization path +omits capabilities that the compact runtime cannot execute. Omitted settings +are silent no-ops and must not be advertised as live network capabilities. diff --git a/Cargo.lock b/Cargo.lock index 5f73a262..68c53158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2528,6 +2528,15 @@ dependencies = [ "windows 0.52.0", ] +[[package]] +name = "easytier-mini" +version = "2.6.4" +dependencies = [ + "anyhow", + "easytier", + "tokio", +] + [[package]] name = "easytier-proto" version = "2.6.4" diff --git a/Cargo.toml b/Cargo.toml index 9e76b9b7..0e712a08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "easytier", "easytier-gui/src-tauri", "easytier-web", + "easytier-contrib/easytier-mini", "easytier-contrib/easytier-ffi", "easytier-contrib/easytier-uptime", "easytier-contrib/easytier-android-jni", @@ -29,3 +30,8 @@ lto = true codegen-units = 1 opt-level = 3 strip = true + +[profile.mini] +inherits = "release" +opt-level = "z" +strip = "symbols" diff --git a/docs/core-architecture.md b/docs/core-architecture.md index 83c5ff5d..b7458b9b 100644 --- a/docs/core-architecture.md +++ b/docs/core-architecture.md @@ -217,7 +217,10 @@ are not independent owners of EasyTier peer state. Each protocol registration must provide a coherent client/server Adapter. Unavailable configured transports must be rejected during validation or -protocol selection, rather than silently falling back to another transport. +protocol selection in the standard runtime, rather than silently falling back +to another transport. A compact compatibility Host may instead retain the +desired value for management readback and omit it from normalized runtime +state; it must not advertise or partially activate the unavailable transport. ### Connectivity @@ -283,8 +286,9 @@ Adapters. Optional gateway capabilities are selected by cohesive Modules. Disabled implementations retain stable lifecycle calls and report unsupported -configuration where a stable interface is required; they do not duplicate -portable policy. +configuration in the standard runtime. A compact compatibility Host may +silently normalize those settings to no-ops while preserving the desired TOML +model; disabled implementations do not duplicate portable policy. The instance-scoped `DataPlaneSession` composes the foundation operation broker under the same session lock as its resource and quota state. The broker owns @@ -368,10 +372,12 @@ configuration, or connectivity state. ## Runtime configuration authority -`TomlConfig` is an owned construction input. After startup, it is not a second -mutable source of truth. +`TomlConfig` is the authoritative desired configuration used for management +readback and patch transactions. Compact Hosts keep unsupported accepted values +there so controllers observe the configuration they submitted. -The normalized core runtime store is authoritative for: +The separately typed, normalized core runtime store is authoritative for live +behavior: - peer feature flags and routing policy; - listeners and initial peers; @@ -477,8 +483,9 @@ configuration, management, packet-plane, or test-support interfaces. 14. Unknown protobuf fields in reflected route information survive forwarding and credential filtering. 15. Feature selection is localized at cohesive Module/Adapter boundaries. -16. Unsupported configured capabilities fail explicitly rather than changing - wire protocol or silently falling back. +16. The standard runtime rejects unsupported configured capabilities. Compact + compatibility Hosts may preserve them as runtime no-ops, but never change + wire protocol, advertise them, or silently fall back to an unsafe mode. ## Validation diff --git a/easytier-contrib/easytier-mini/Cargo.toml b/easytier-contrib/easytier-mini/Cargo.toml new file mode 100644 index 00000000..a633f898 --- /dev/null +++ b/easytier-contrib/easytier-mini/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "easytier-mini" +description = "Minimal native EasyTier node with TCP/UDP tunnels, TUN and UDP hole punching." +version = "2.6.4" +edition.workspace = true +rust-version.workspace = true +license-file = "../../LICENSE" +build = "build.rs" + +[dependencies] +anyhow = "1.0" +easytier = { path = "../../easytier", version = "2.6.4", default-features = false, features = [ + "aes-gcm", + "dhcp-ipv4", + "logging", + "proxy-cidr-monitor", + "smoltcp", + "tun", + "web-client", +] } +tokio = { version = "1", default-features = false, features = ["macros", "rt", "signal"] } diff --git a/easytier-contrib/easytier-mini/README.md b/easytier-contrib/easytier-mini/README.md new file mode 100644 index 00000000..b1d800fc --- /dev/null +++ b/easytier-contrib/easytier-mini/README.md @@ -0,0 +1,113 @@ +# easytier-mini + +`easytier-mini` is a native EasyTier POC binary. It shares EasyTier's TOML +configuration model, peer protocol, TCP/UDP tunnel implementations, TUN, +dynamic IPv4 allocation, the smoltcp userspace path and STUN/UDP hole-punching +core with the full binary. It includes AES-GCM so its default encryption +setting interoperates with the full binary's default configuration. + +Build it with: + +```sh +cargo build --release -p easytier-mini +``` + +For the static size target used by this POC: + +```sh +cargo build --profile mini --target x86_64-unknown-linux-musl -p easytier-mini +``` + +MIPS targets use the repository's existing musl-cross toolchains. The helper +builds the standard library for size, applies immediate-abort only to the mini +MIPS target graph, and can build either or both byte orders: + +```sh +./easytier-contrib/easytier-mini/build-mips.sh all +./easytier-contrib/easytier-mini/build-mips.sh mips +./easytier-contrib/easytier-mini/build-mips.sh mipsel +``` + +The `mini` profile derives from `release` and applies `opt-level=z` to the +entire compact binary dependency graph. Full EasyTier release builds retain +their normal `opt-level=3` profile. The musl builds use a mini-only static +linker policy to stay below 5,000,000 bytes on x86-64 and 5,500,000 bytes on +MIPS without UPX or another executable compressor. The compact x86-64 linker +policy retains static PIE, packs relative relocations and folds identical code. +MIPS builds omit standard-library backtrace support and use immediate abort; +normal workspace MIPS builds are not affected. Compact linker policies omit +unwind tables. + +Start it with a normal EasyTier TOML file: + +```sh +easytier-mini --config mini.toml +``` + +`-c` is accepted as the short form of `--config`. + +Start it as an EasyTier Web managed node with a complete config-server URL: + +```sh +easytier-mini --config-server udp://config-server.easytier.cn:22020/TOKEN +``` + +`--machine-id`, `--hostname`, and `--secure-mode` match the full client's Web +identity and transport options. `--config` and `--config-server` may be used +together: the local instance remains static while Web-owned instances are +created, updated, retained, and deleted independently. + +The node also exposes the native EasyTier management RPC protocol on +`127.0.0.1:15888`, so the full `easytier-cli` can inspect it: + +```sh +easytier-cli node info +easytier-cli peer +easytier-cli route +easytier-cli connector list +``` + +For example: + +```toml +instance_name = "mini" +ipv4 = "10.147.0.2" +listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010"] + +[network_identity] +network_name = "mini-poc" +network_secret = "change-me" + +[[peer]] +uri = "tcp://example.net:11010" +``` + +Local TOML and Web configuration both retain the complete authoritative model. +The compact runtime silently omits unsupported capabilities while normalizing +that model into live runtime state. EasyTier Web therefore sees every accepted +configuration value unchanged and its consistency checks converge. This also +applies to hot patches: for example, a port-forward patch remains visible to +the controller while no port-forward service starts in mini. ChaCha20 falls +back to AES-GCM rather than plaintext. + +The compact runtime supports `tcp://` and `udp://` listener, mapped-listener +and peer URLs. `no_tun = true` runs through smoltcp without an OS TUN device, +and `dhcp = true` allocates the virtual IPv4 address dynamically. + +The mini feature set keeps STUN collection, UDP hole punching, Web heartbeats, +Web instance lifecycle management and the config hot-patch RPC. It omits TCP +hole punching, endpoint discovery (`http://`, `https://`, `txt://` and +`srv://` peers), protobuf reflection, logger control and the rest of the full +management surface. Unsupported connector URLs are accepted as no-ops. Its +local RPC surface remains read-only for node, peer, route and connector +queries. OSPF route messages keep their original protobuf wire data, so fields +added by future EasyTier versions are forwarded without requiring +`prost-reflect`. + +For size, this POC reads one file directly and does not support configuration +from stdin or `${VAR}` expansion. It omits the process-management event journal, +while the console logger still reports runtime events such as peer, connection, +listener, TUN and DHCP changes. The RPC address is currently fixed, so only one +mini process can use the default portal on a host. The x86-64 musl POC cannot +provide reliable stack backtraces because its release binary has no unwind +tables. diff --git a/easytier-contrib/easytier-mini/build-mips.sh b/easytier-contrib/easytier-mini/build-mips.sh new file mode 100755 index 00000000..4996a6cf --- /dev/null +++ b/easytier-contrib/easytier-mini/build-mips.sh @@ -0,0 +1,61 @@ +#!/bin/sh +set -eu + +# Cargo invokes this same file as a rustc wrapper during compact MIPS builds. +# Applying immediate-abort here keeps the size policy scoped to easytier-mini; +# normal MIPS builds elsewhere in the workspace retain their panic behavior. +if [ "${EASYTIER_MINI_MIPS_RUSTC_WRAPPER:-}" = "1" ]; then + mini_rustc=$1 + shift + for mini_rustc_arg in "$@"; do + case "$mini_rustc_arg" in + mips-unknown-linux-musl|mipsel-unknown-linux-musl) + exec "$mini_rustc" "$@" \ + -Zunstable-options \ + -Cpanic=immediate-abort + ;; + esac + done + exec "$mini_rustc" "$@" +fi + +mini_script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mini_repo_dir=$(CDPATH= cd -- "$mini_script_dir/../.." && pwd) +mini_requested_target=${1:-all} +cd "$mini_repo_dir" + +build_mips_target() { + mini_target=$1 + mini_toolchain=$2 + PATH="$mini_repo_dir/musl_gcc/$mini_toolchain/bin:$PATH" \ + EASYTIER_MINI_MIPS_RUSTC_WRAPPER=1 \ + RUSTC_BOOTSTRAP=1 \ + RUSTC_WRAPPER="$mini_script_dir/build-mips.sh" \ + cargo build \ + --manifest-path "$mini_repo_dir/Cargo.toml" \ + --profile mini \ + --target "$mini_target" \ + -Z build-std=std \ + -Z build-std-features=optimize_for_size \ + -p easytier-mini +} + +case "$mini_requested_target" in + all) + build_mips_target mips-unknown-linux-musl mips-unknown-linux-muslsf + build_mips_target mipsel-unknown-linux-musl mipsel-unknown-linux-muslsf + ;; + mips|mips-unknown-linux-musl) + build_mips_target mips-unknown-linux-musl mips-unknown-linux-muslsf + ;; + mipsel|mipsel-unknown-linux-musl) + build_mips_target mipsel-unknown-linux-musl mipsel-unknown-linux-muslsf + ;; + -h|--help) + echo "usage: $0 [all|mips|mipsel]" + ;; + *) + echo "unsupported MIPS target: $mini_requested_target" >&2 + exit 2 + ;; +esac diff --git a/easytier-contrib/easytier-mini/build.rs b/easytier-contrib/easytier-mini/build.rs new file mode 100644 index 00000000..5a379f4e --- /dev/null +++ b/easytier-contrib/easytier-mini/build.rs @@ -0,0 +1,32 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let target = env::var("TARGET").unwrap_or_default(); + let profile = env::var("PROFILE").unwrap_or_default(); + if !matches!(profile.as_str(), "release" | "mini") + || !matches!( + target.as_str(), + "x86_64-unknown-linux-musl" | "mips-unknown-linux-musl" | "mipsel-unknown-linux-musl" + ) + { + return; + } + + let script = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("easytier-mini-musl.ld"); + println!("cargo:rerun-if-changed={}", script.display()); + // The release-derived mini profile already aborts panics. Keep the compact + // binary's linker policy local so full EasyTier musl builds retain their + // normal PIE/unwind settings. + println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--build-id=none"); + if target == "x86_64-unknown-linux-musl" { + println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--pack-dyn-relocs=relr"); + println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--icf=all"); + } + println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--no-eh-frame-hdr"); + println!( + "cargo:rustc-link-arg-bin=easytier-mini=-Wl,-T,{}", + script.display() + ); +} diff --git a/easytier-contrib/easytier-mini/easytier-mini-musl.ld b/easytier-contrib/easytier-mini/easytier-mini-musl.ld new file mode 100644 index 00000000..c4b58733 --- /dev/null +++ b/easytier-contrib/easytier-mini/easytier-mini-musl.ld @@ -0,0 +1,14 @@ +SECTIONS +{ + .eh_frame : + { + KEEP(*crtbegin.o(.eh_frame)) + KEEP(*crtend.o(.eh_frame)) + } + /DISCARD/ : + { + *(EXCLUDE_FILE (*crtbegin.o *crtend.o) .eh_frame) + *(.eh_frame_hdr) + } +} +INSERT AFTER .data; diff --git a/easytier-contrib/easytier-mini/src/main.rs b/easytier-contrib/easytier-mini/src/main.rs new file mode 100644 index 00000000..add31201 --- /dev/null +++ b/easytier-contrib/easytier-mini/src/main.rs @@ -0,0 +1,264 @@ +use std::{ffi::OsString, path::PathBuf, sync::Arc}; + +use anyhow::Context as _; +use easytier::common::MachineIdOptions; +use easytier::{ + common::config::{ConfigFileControl, load_toml_config_from_path}, + instance::factory::native_compact_instance_manager_with_runtime, + rpc_service::ReadOnlyApiRpcServer, + web_client::{WebClientHooks, parse_config_server_endpoint, run_web_client}, +}; + +enum Command { + Run(RunOptions), + Exit, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct RunOptions { + config: Option, + config_server: Option, + machine_id: Option, + hostname: Option, + secure_mode: bool, +} + +const USAGE: &str = "usage: easytier-mini [--config ] [--config-server ] \ + [--machine-id ] [--hostname ] [--secure-mode]"; + +fn required_value( + args: &mut impl Iterator, + option: &str, +) -> anyhow::Result { + args.next() + .with_context(|| format!("{option} requires a value")) +} + +fn parse_args(mut args: impl Iterator) -> anyhow::Result { + let mut options = RunOptions::default(); + while let Some(arg) = args.next() { + if arg == "-h" || arg == "--help" { + println!( + "easytier-mini {}\n\nUsage: {USAGE}", + env!("CARGO_PKG_VERSION") + ); + return Ok(Command::Exit); + } + if arg == "-V" || arg == "--version" { + println!("easytier-mini {}", env!("CARGO_PKG_VERSION")); + return Ok(Command::Exit); + } + if arg == "-c" || arg == "--config" { + if options.config.is_some() { + anyhow::bail!("--config may only be specified once"); + } + options.config = Some(PathBuf::from(required_value(&mut args, "--config")?)); + continue; + } + if arg == "-w" || arg == "--config-server" { + if options.config_server.is_some() { + anyhow::bail!("--config-server may only be specified once"); + } + options.config_server = Some( + required_value(&mut args, "--config-server")? + .into_string() + .map_err(|_| anyhow::anyhow!("--config-server must be valid UTF-8"))?, + ); + continue; + } + if arg == "--machine-id" { + options.machine_id = Some( + required_value(&mut args, "--machine-id")? + .into_string() + .map_err(|_| anyhow::anyhow!("--machine-id must be valid UTF-8"))?, + ); + continue; + } + if arg == "--hostname" { + options.hostname = Some( + required_value(&mut args, "--hostname")? + .into_string() + .map_err(|_| anyhow::anyhow!("--hostname must be valid UTF-8"))?, + ); + continue; + } + if arg == "--secure-mode" { + options.secure_mode = true; + continue; + } + anyhow::bail!("unknown argument {arg:?}; {USAGE}"); + } + if options.config.is_none() && options.config_server.is_none() { + anyhow::bail!("either --config or --config-server is required; {USAGE}"); + } + Ok(Command::Run(options)) +} + +fn require_tcp_or_udp(scheme: &str, source: &str) -> anyhow::Result<()> { + match scheme { + "tcp" | "udp" => Ok(()), + scheme => anyhow::bail!( + "{source} uses unsupported tunnel scheme {scheme:?}; easytier-mini supports only tcp:// and udp://" + ), + } +} + +fn validate_config_server(config_server: &str) -> anyhow::Result<()> { + let endpoint = parse_config_server_endpoint(config_server)?; + require_tcp_or_udp(endpoint.connect_url().scheme(), "config server") +} + +struct MiniWebClientHooks; + +impl WebClientHooks for MiniWebClientHooks { + fn manages_remote_config_instances(&self) -> bool { + true + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let Command::Run(options) = parse_args(std::env::args_os().skip(1))? else { + return Ok(()); + }; + easytier::common::log::init_console()?; + let local_config = options + .config + .as_ref() + .map(|config_path| { + load_toml_config_from_path(config_path) + .with_context(|| format!("failed to load {}", config_path.display())) + }) + .transpose()?; + if let Some(config_server) = options.config_server.as_deref() { + validate_config_server(config_server)?; + } + + let instances = Arc::new(native_compact_instance_manager_with_runtime( + tokio::runtime::Handle::current(), + )); + let local_instance_id = local_config + .map(|config| instances.run_network_instance(config, ConfigFileControl::STATIC_CONFIG)) + .transpose()?; + let _web_client = if let Some(config_server) = options.config_server.as_deref() { + Some( + run_web_client( + config_server, + MachineIdOptions { + explicit_machine_id: options.machine_id, + state_dir: None, + }, + options.hostname, + options.secure_mode, + instances.clone(), + Some(Arc::new(MiniWebClientHooks)), + ) + .await?, + ) + } else { + None + }; + let _rpc_server = + ReadOnlyApiRpcServer::new(Some("127.0.0.1:15888".to_owned()), None, instances.clone())? + .serve() + .await?; + eprintln!( + "easytier-mini started: local={local_instance_id:?}, web={}; RPC: 127.0.0.1:15888", + options.config_server.is_some() + ); + + let stopped_unexpectedly = tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal.context("failed to listen for Ctrl-C")?; + false + }, + _ = instances.wait() => true, + }; + + for instance in instances.instances() { + instance.stop().await; + } + if stopped_unexpectedly { + anyhow::bail!("EasyTier instance stopped unexpectedly"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use easytier::common::config::{ConfigLoader as _, TomlConfigLoader}; + + #[test] + fn parses_minimal_config_argument() { + let Command::Run(options) = + parse_args([OsString::from("--config"), OsString::from("mini.toml")].into_iter()) + .unwrap() + else { + panic!("expected run command"); + }; + + assert_eq!(options.config, Some(PathBuf::from("mini.toml"))); + } + + #[test] + fn parses_web_client_arguments_without_a_local_config() { + let Command::Run(options) = parse_args( + [ + OsString::from("--config-server"), + OsString::from("token"), + OsString::from("--machine-id"), + OsString::from("machine"), + OsString::from("--hostname"), + OsString::from("mini"), + OsString::from("--secure-mode"), + ] + .into_iter(), + ) + .unwrap() else { + panic!("expected run command"); + }; + + assert_eq!(options.config_server.as_deref(), Some("token")); + assert_eq!(options.machine_id.as_deref(), Some("machine")); + assert_eq!(options.hostname.as_deref(), Some("mini")); + assert!(options.secure_mode); + } + + #[test] + fn rejects_unknown_arguments() { + let result = parse_args([OsString::from("extra")].into_iter()); + + assert!(result.is_err()); + } + + #[test] + fn accepts_tcp_udp_config_server() { + assert!(validate_config_server("udp://127.0.0.1:22020/token").is_ok()); + assert!(validate_config_server("quic://127.0.0.1:22020/token").is_err()); + } + + #[tokio::test] + async fn compact_factory_accepts_unsupported_config_without_changing_it() { + let config = TomlConfigLoader::new_from_str( + r#" +dhcp = true +listeners = ["quic://127.0.0.1:11010"] +proxy_network = [{ cidr = "10.20.0.0/16" }] + +[flags] +encryption_algorithm = "chacha20" +data_compress_algo = "Zstd" +"#, + ) + .unwrap(); + config.get_id(); + let before = config.dump(); + let manager = + native_compact_instance_manager_with_runtime(tokio::runtime::Handle::current()); + + let instance = manager.create(config, ()).unwrap(); + + assert_eq!(instance.toml_config().unwrap().dump(), before); + } +} diff --git a/easytier-core/Cargo.toml b/easytier-core/Cargo.toml index 939a99fe..29f4e72c 100644 --- a/easytier-core/Cargo.toml +++ b/easytier-core/Cargo.toml @@ -121,7 +121,8 @@ extended-services = [ "wrapped-transport", "proxy-cidr-monitor", ] -management = ["management-rpc", "config-write", "extended-services", "rich-config-errors", "easytier-proto/json-rpc"] +web-client = ["management-rpc", "config-write"] +management = ["web-client", "extended-services", "rich-config-errors", "easytier-proto/json-rpc"] management-rpc = ["easytier-proto/api"] proxy-cidr-monitor = [] rich-config-errors = ["dep:ariadne"] diff --git a/easytier-core/src/config/mod.rs b/easytier-core/src/config/mod.rs index 88314f41..d7974cfb 100644 --- a/easytier-core/src/config/mod.rs +++ b/easytier-core/src/config/mod.rs @@ -1,8 +1,8 @@ //! Static configuration schema plus the live runtime configuration store. -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] pub mod api; -#[cfg(any(feature = "management", feature = "browser-config"))] +#[cfg(any(feature = "web-client", feature = "browser-config"))] pub mod api_input; #[cfg(all( feature = "browser-config", diff --git a/easytier-core/src/config/runtime.rs b/easytier-core/src/config/runtime.rs index c3a64a0f..6b2ae996 100644 --- a/easytier-core/src/config/runtime.rs +++ b/easytier-core/src/config/runtime.rs @@ -95,6 +95,23 @@ impl CoreRuntimeConfigStore { .send_modify(|version| *version += 1); } + pub(crate) fn replace_with_current( + &self, + mut config: CoreInstanceRuntimeConfig, + merge: impl FnOnce(&CoreInstanceRuntimeConfig, &mut CoreInstanceRuntimeConfig), + ) -> Arc { + let _update = self.inner.update.lock(); + let current = self.inner.snapshot.load_full(); + merge(¤t, &mut config); + let config = Arc::new(config); + self.inner.snapshot.store(config.clone()); + self.inner.peer_changes.send_modify(|version| *version += 1); + self.inner + .service_changes + .send_modify(|version| *version += 1); + config + } + pub fn update_services(&self, update: impl FnOnce(&mut CoreRuntimeConfig)) { let _update = self.inner.update.lock(); let mut config = self.inner.snapshot.load_full().as_ref().clone(); diff --git a/easytier-core/src/config/toml.rs b/easytier-core/src/config/toml.rs index 6ca0eeeb..1a1f0a1d 100644 --- a/easytier-core/src/config/toml.rs +++ b/easytier-core/src/config/toml.rs @@ -624,7 +624,7 @@ impl TomlConfig { } } -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] mod snapshot; impl ConfigLoader for TomlConfig { diff --git a/easytier-core/src/instance/config.rs b/easytier-core/src/instance/config.rs index e42c293c..87622495 100644 --- a/easytier-core/src/instance/config.rs +++ b/easytier-core/src/instance/config.rs @@ -4,11 +4,11 @@ use std::collections::BTreeSet; use crate::{ config::{ - IpPrefix, NodeConfig, ProxyNetworkConfig, RouteConfig, + EncryptionAlgorithm, IpPrefix, NodeConfig, ProxyNetworkConfig, RouteConfig, gateway::{GatewayRuntimeConfig, ProxyRuntimeConfig}, peers::{AclRuleConfig, HostRoutingPolicy, PublicIpv6ProviderConfig}, runtime::CoreRuntimeConfig, - toml::{ConfigLoader as _, TomlConfig}, + toml::{ConfigLoader as _, Flags, TomlConfig}, }, connectivity::{ direct::DirectConnectorOptions, @@ -16,13 +16,17 @@ use crate::{ stun::StunServerConfig, }, listener::plan::ListenerRuntimeConfig, + packet::CompressorAlgo, peers::{ context::PeerRuntimeSnapshotInput, peer_manager::{PortablePeerManagerConfig, RouteAlgoType}, }, socket::{NetNamespace, SocketContext, tcp::TcpBindOptions, udp::UdpBindOptions}, + tunnel::encrypt::algorithm_is_available, }; +use easytier_proto::common::CompressionAlgoPb; + use super::{CoreConnectivityConfig, CoreInstanceConfig}; const OSPF_UPDATE_MY_FOREIGN_NETWORK_INTERVAL_SEC: u64 = 10; @@ -44,6 +48,15 @@ pub struct CoreInstanceHostConfig { pub icmp_failure_is_fatal: bool, pub public_ipv6_provider_supported: bool, pub gateway_enabled: bool, + pub proxy_enabled: bool, + pub vpn_portal_enabled: bool, + pub magic_dns_enabled: bool, + pub kcp_enabled: bool, + pub quic_enabled: bool, + pub udp_broadcast_enabled: bool, + pub upnp_enabled: bool, + pub tcp_hole_punching_enabled: bool, + pub ignore_unsupported_config: bool, pub easytier_version: String, pub endpoint_protocols: Vec, } @@ -60,12 +73,87 @@ impl Default for CoreInstanceHostConfig { icmp_failure_is_fatal: false, public_ipv6_provider_supported: false, gateway_enabled: true, + proxy_enabled: true, + vpn_portal_enabled: true, + magic_dns_enabled: true, + kcp_enabled: true, + quic_enabled: true, + udp_broadcast_enabled: true, + upnp_enabled: true, + tcp_hole_punching_enabled: true, + ignore_unsupported_config: false, easytier_version: env!("CARGO_PKG_VERSION").to_owned(), endpoint_protocols: ManualEndpointDiscoveryConfig::default().srv_protocols, } } } +impl CoreInstanceHostConfig { + pub(crate) fn accepts_runtime_url(&self, url: &url::Url) -> bool { + !self.ignore_unsupported_config + || self + .endpoint_protocols + .iter() + .any(|scheme| scheme.eq_ignore_ascii_case(url.scheme())) + } + + fn runtime_flags(&self, mut flags: Flags) -> Flags { + if !self.ignore_unsupported_config { + return flags; + } + + if !self.smoltcp_available { + flags.no_tun = false; + flags.use_smoltcp = false; + } + if !self.proxy_enabled { + flags.enable_exit_node = false; + } + if !self.magic_dns_enabled { + flags.accept_dns = false; + } + if !self.kcp_enabled { + flags.enable_kcp_proxy = false; + flags.disable_kcp_input = true; + flags.disable_relay_kcp = true; + flags.enable_relay_foreign_network_kcp = false; + } + if !self.quic_enabled { + flags.enable_quic_proxy = false; + flags.disable_quic_input = true; + flags.disable_relay_quic = true; + flags.enable_relay_foreign_network_quic = false; + } + if !self.udp_broadcast_enabled { + flags.enable_udp_broadcast_relay = false; + } + if !self.upnp_enabled { + flags.disable_upnp = true; + } + if !self.tcp_hole_punching_enabled { + flags.disable_tcp_hole_punching = true; + } + if CompressionAlgoPb::try_from(flags.data_compress_algo) + .ok() + .and_then(|algorithm| CompressorAlgo::try_from(algorithm).ok()) + .is_some_and(|algorithm| !algorithm.is_available()) + { + flags.data_compress_algo = CompressionAlgoPb::None as i32; + } + + if flags + .encryption_algorithm + .parse::() + .is_ok_and(|algorithm| !algorithm_is_available(algorithm)) + && algorithm_is_available(EncryptionAlgorithm::AesGcm) + { + flags.encryption_algorithm = EncryptionAlgorithm::AesGcm.to_string(); + } + + flags + } +} + impl CoreInstanceConfig { /// Normalizes the complete shared TOML model using OS-independent defaults. /// @@ -81,7 +169,7 @@ impl CoreInstanceConfig { config: &TomlConfig, host: &CoreInstanceHostConfig, ) -> anyhow::Result { - let flags = config.get_flags(); + let flags = host.runtime_flags(config.get_flags()); let instance_id = config.get_id(); let identity: crate::config::NetworkIdentity = config.get_network_identity().into(); let network_name = identity.network_name.clone(); @@ -93,6 +181,16 @@ impl CoreInstanceConfig { _ => host.hostname_fallback.clone().unwrap_or_default(), }; let acl = config.get_acl(); + let peers = config + .get_peers() + .into_iter() + .filter(|peer| host.accepts_runtime_url(&peer.uri)) + .collect::>(); + let proxy_networks = if host.ignore_unsupported_config && !host.proxy_enabled { + Vec::new() + } else { + config.get_proxy_cidrs() + }; let peer_snapshot = crate::config::peers::PeerRuntimeSnapshot::from_host_input(PeerRuntimeSnapshotInput { @@ -111,8 +209,7 @@ impl CoreInstanceConfig { address: value.address().into(), prefix_len: value.network_length(), }), - proxy_networks: config - .get_proxy_cidrs() + proxy_networks: proxy_networks .into_iter() .map(|proxy| ProxyNetworkConfig { real: IpPrefix { @@ -134,12 +231,13 @@ impl CoreInstanceConfig { host_routing: host.host_routing, acl: acl.clone(), easytier_version: host.easytier_version.clone(), - vpn_portal_cidr: config - .get_vpn_portal_config() + vpn_portal_cidr: (!host.ignore_unsupported_config || host.vpn_portal_enabled) + .then(|| config.get_vpn_portal_config()) + .flatten() .map(|portal| portal.client_cidr), - pinned_peers: config - .get_peers() - .into_iter() + pinned_peers: peers + .iter() + .cloned() .map(|peer| (peer.uri, peer.peer_public_key)) .collect(), ospf_update_my_foreign_network_interval_sec: @@ -151,19 +249,28 @@ impl CoreInstanceConfig { let peer = PortablePeerManagerConfig { snapshot: peer_snapshot, route_algo: RouteAlgoType::Ospf, - exit_nodes: config.get_exit_nodes(), - foreign_context_default_flags: TomlConfig::default().get_flags(), + exit_nodes: if host.ignore_unsupported_config && !host.proxy_enabled { + Vec::new() + } else { + config.get_exit_nodes() + }, + foreign_context_default_flags: host.runtime_flags(TomlConfig::default().get_flags()), }; let tcp_bind = TcpBindOptions::default().with_context(socket_context.clone()); let udp_bind = UdpBindOptions::direct_connect().with_context(socket_context.clone()); let listeners = Some(ListenerRuntimeConfig::new( - config.get_listener_uris(), + config + .get_listener_uris() + .into_iter() + .filter(|url| host.accepts_runtime_url(url)) + .collect(), flags.enable_ipv6, socket_context.clone(), )); - let socks5_bind = config - .get_socks5_portal() + let socks5_bind = (!host.ignore_unsupported_config || host.gateway_enabled) + .then(|| config.get_socks5_portal()) + .flatten() .map(|url| { let host = url .host_str() @@ -186,7 +293,11 @@ impl CoreInstanceConfig { dhcp_ipv4: config.get_dhcp(), gateway: GatewayRuntimeConfig { socks5_bind, - port_forwards: config.get_port_forwards(), + port_forwards: if host.ignore_unsupported_config && !host.gateway_enabled { + Vec::new() + } else { + config.get_port_forwards() + }, }, manual_routes: config .get_routes() @@ -200,10 +311,15 @@ impl CoreInstanceConfig { icmp_failure_is_fatal: host.icmp_failure_is_fatal, udp_response_ipv4_mtu: 1280, }, - public_ipv6_auto: config.get_ipv6_public_addr_auto(), + public_ipv6_auto: config.get_ipv6_public_addr_auto() + && (!host.ignore_unsupported_config || host.public_ipv6_provider_supported), public_ipv6_provider: PublicIpv6ProviderConfig { - provider_enabled: config.get_ipv6_public_addr_provider(), - configured_prefix: config.get_ipv6_public_addr_prefix(), + provider_enabled: config.get_ipv6_public_addr_provider() + && (!host.ignore_unsupported_config || host.public_ipv6_provider_supported), + configured_prefix: (!host.ignore_unsupported_config + || host.public_ipv6_provider_supported) + .then(|| config.get_ipv6_public_addr_prefix()) + .flatten(), provider_supported: host.public_ipv6_provider_supported, }, }; @@ -212,11 +328,7 @@ impl CoreInstanceConfig { instance_name: config.get_inst_name(), peer, connectivity: CoreConnectivityConfig { - initial_peers: config - .get_peers() - .into_iter() - .map(|peer| peer.uri) - .collect(), + initial_peers: peers.into_iter().map(|peer| peer.uri).collect(), listeners, runtime, startup_plan: super::CoreInstanceStartupPlan { @@ -341,6 +453,7 @@ disable_p2p = true gateway_enabled: false, easytier_version: "host-version".to_owned(), endpoint_protocols: vec!["host-protocol".to_owned()], + ..Default::default() }; let normalized = CoreInstanceConfig::from_toml_with_host(&config, &host).unwrap(); @@ -383,4 +496,97 @@ disable_p2p = true ["host-protocol"] ); } + + #[test] + fn ignored_capabilities_stay_in_toml_but_not_runtime_config() { + let config = TomlConfig::new_from_str( + r#" +listeners = ["tcp://127.0.0.1:11010", "quic://127.0.0.1:11011"] +proxy_network = [{ cidr = "10.20.0.0/16" }] + +[[peer]] +uri = "tcp://127.0.0.1:11010" + +[[peer]] +uri = "quic://127.0.0.1:11011" + +[flags] +enable_exit_node = true +enable_kcp_proxy = true +accept_dns = true +encryption_algorithm = "chacha20" +data_compress_algo = "Zstd" +"#, + ) + .unwrap(); + config.set_exit_nodes(vec!["10.144.144.2".parse().unwrap()]); + config.set_ipv6_public_addr_provider(true); + config.get_id(); + let before = config.dump(); + + let host = CoreInstanceHostConfig { + ignore_unsupported_config: true, + smoltcp_available: true, + proxy_enabled: false, + gateway_enabled: false, + public_ipv6_provider_supported: false, + magic_dns_enabled: false, + kcp_enabled: false, + quic_enabled: false, + endpoint_protocols: vec!["tcp".to_owned(), "udp".to_owned()], + ..Default::default() + }; + + let normalized = CoreInstanceConfig::from_toml_with_host(&config, &host).unwrap(); + + assert_eq!(config.dump(), before); + assert_eq!(normalized.connectivity.initial_peers.len(), 1); + assert_eq!( + normalized + .connectivity + .listeners + .as_ref() + .unwrap() + .urls + .len(), + 1 + ); + assert!( + normalized + .peer + .snapshot + .runtime + .core + .routes + .proxy_networks + .is_empty() + ); + assert!(normalized.peer.exit_nodes.is_empty()); + assert!(!normalized.connectivity.runtime.proxy.enable_exit_node); + assert!( + !normalized + .connectivity + .runtime + .public_ipv6_provider + .provider_enabled + ); + let flags = &normalized.peer.snapshot.flags; + assert!(!flags.enable_kcp_proxy); + assert!(flags.disable_kcp_input); + assert!(!flags.accept_dns); + let expected_encryption = if algorithm_is_available(EncryptionAlgorithm::ChaCha20) { + EncryptionAlgorithm::ChaCha20 + } else { + EncryptionAlgorithm::AesGcm + }; + assert_eq!(flags.encryption_algorithm, expected_encryption.to_string()); + assert_eq!( + flags.data_compress_algo, + if CompressorAlgo::ZstdDefault.is_available() { + CompressionAlgoPb::Zstd as i32 + } else { + CompressionAlgoPb::None as i32 + } + ); + } } diff --git a/easytier-core/src/instance/management_extension.rs b/easytier-core/src/instance/management_extension.rs index 93c0f46b..b1144a13 100644 --- a/easytier-core/src/instance/management_extension.rs +++ b/easytier-core/src/instance/management_extension.rs @@ -1,7 +1,3 @@ -use std::sync::Arc; - -use crate::config::runtime::CoreInstanceRuntimeConfig; - use super::{CoreInstance, CoreInstanceHost, CoreInstanceHostConfig}; impl CoreInstance @@ -12,10 +8,6 @@ where self.management.toml_config() } - pub(crate) fn runtime_config_snapshot(&self) -> Arc { - self.runtime_config.snapshot() - } - pub(crate) fn host_config(&self) -> &CoreInstanceHostConfig { self.management.host_config() } diff --git a/easytier-core/src/instance/management_state.rs b/easytier-core/src/instance/management_state.rs index 6f67af3c..eac13773 100644 --- a/easytier-core/src/instance/management_state.rs +++ b/easytier-core/src/instance/management_state.rs @@ -1,9 +1,9 @@ use crate::{config::toml::TomlConfig, instance::CoreInstanceHostConfig}; pub(super) struct ManagementState { - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] toml_config: Option, - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] host_config: CoreInstanceHostConfig, } @@ -12,22 +12,22 @@ impl ManagementState { toml_config: Option, host_config: CoreInstanceHostConfig, ) -> Self { - #[cfg(not(feature = "management"))] + #[cfg(not(feature = "web-client"))] let _ = (toml_config, host_config); Self { - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] toml_config, - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] host_config, } } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] pub(super) fn toml_config(&self) -> Option { self.toml_config.clone() } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] pub(super) fn host_config(&self) -> &CoreInstanceHostConfig { &self.host_config } diff --git a/easytier-core/src/instance/manager.rs b/easytier-core/src/instance/manager.rs index b57891b0..7e7935e8 100644 --- a/easytier-core/src/instance/manager.rs +++ b/easytier-core/src/instance/manager.rs @@ -17,12 +17,12 @@ use uuid::Uuid; use crate::config::toml::TomlConfig; use crate::instance::{CoreInstance, CoreInstanceHost}; use crate::process_runtime::CoreProcessRuntime; -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] use crate::{ config::toml::{ConfigLoader as _, ConfigSource}, management::network_instance_running_info, }; -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] use easytier_proto::api::manage::NetworkInstanceRunningInfo; /// Stable identity required by the instance collection. @@ -439,25 +439,25 @@ where } } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] pub fn config(&self, instance_id: Uuid) -> Option { self.get(instance_id) .and_then(|instance| instance.toml_config()) } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] pub fn config_source(&self, instance_id: Uuid) -> Option { self.config(instance_id) .map(|config| config.get_network_config_source()) } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] pub async fn network_info(&self, instance_id: Uuid) -> Option { let instance = self.get(instance_id)?; network_instance_running_info(instance.as_ref()).await.ok() } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] pub async fn collect_network_infos( &self, ) -> anyhow::Result> { @@ -471,7 +471,7 @@ where Ok(result) } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] pub fn collect_network_infos_sync( &self, ) -> anyhow::Result> { diff --git a/easytier-core/src/instance/mod.rs b/easytier-core/src/instance/mod.rs index 1451aba9..5036f46a 100644 --- a/easytier-core/src/instance/mod.rs +++ b/easytier-core/src/instance/mod.rs @@ -6,7 +6,7 @@ mod config; mod data_plane_extension; mod lifecycle; mod management; -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] mod management_extension; mod management_state; pub mod manager; @@ -214,6 +214,23 @@ fn retain_core_peer_identity( peer.runtime.core.node.instance_id = instance_id; } +fn retain_runtime_owned_peer_state( + current: &CoreInstanceRuntimeConfig, + next: &mut CoreInstanceRuntimeConfig, + peer_id: crate::config::PeerId, +) { + retain_core_peer_identity( + &mut next.peer, + peer_id, + current.peer.runtime.core.node.instance_id, + ); + let next_peer = Arc::make_mut(&mut next.peer); + next_peer.runtime.stun_info = current.peer.runtime.stun_info.clone(); + if current.services.dhcp_ipv4 && next.services.dhcp_ipv4 { + next_peer.runtime.core.routes.ipv4 = current.peer.runtime.core.routes.ipv4.clone(); + } +} + /// Host-owned resources that must be prepared for the complete Instance /// lifetime, such as a native packet interface. #[async_trait::async_trait] @@ -238,10 +255,15 @@ pub trait InstanceRuntimeHost: std::any::Any + Send + Sync + 'static { /// Applies Host-side cached views of fields already committed to the /// shared TOML model. - #[cfg(feature = "management")] - fn synchronize_config(&self, _patch: &crate::proto::api::config::InstanceConfigPatch) {} + #[cfg(feature = "web-client")] + fn synchronize_config( + &self, + _patch: &crate::proto::api::config::InstanceConfigPatch, + _config: &CoreInstanceRuntimeConfig, + ) { + } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] fn publish_config_patch(&self, _patch: crate::proto::api::config::InstanceConfigPatch) {} fn attach_tun_fd(&self, _fd: i32) -> anyhow::Result<()> { @@ -846,7 +868,7 @@ where pub(crate) async fn update_runtime_config_under_operation( &self, - mut config: CoreInstanceRuntimeConfig, + config: CoreInstanceRuntimeConfig, ) -> anyhow::Result<()> { if matches!( self.state(), @@ -856,21 +878,23 @@ where } self.validate_runtime_config_capabilities(&config)?; let current = self.runtime_config.snapshot(); - retain_core_peer_identity( - &mut config.peer, - self.peer_id(), - current.peer.runtime.core.node.instance_id, - ); let refresh_acl_groups = current.peer.peer_group_memberships != config.peer.peer_group_memberships || current.peer.acl_group_declarations != config.peer.acl_group_declarations; if current.services.acl != config.services.acl { self.reload_acl_config_inner(&config.services.acl).await?; } + // Foreign-network watchers read this state after the runtime-config + // notification, so publish it before replacing the watched snapshot. self.sync_peer_runtime_state(&config.peer); - self.runtime_config.replace(config); + let peer_id = self.peer_id(); + let published = self + .runtime_config + .replace_with_current(config, |current, next| { + retain_runtime_owned_peer_state(current, next, peer_id); + }); self.proxy_cidr_table - .update_snapshot(proxy_cidr_snapshot(self.runtime_config.snapshot().as_ref())); + .update_snapshot(proxy_cidr_snapshot(&published)); if refresh_acl_groups { self.refresh_acl_groups().await; } diff --git a/easytier-core/src/instance/tests.rs b/easytier-core/src/instance/tests.rs index bc87ee19..569963e1 100644 --- a/easytier-core/src/instance/tests.rs +++ b/easytier-core/src/instance/tests.rs @@ -466,6 +466,57 @@ mod portable_runtime { build_with_engines(config, WrappedTransportEngines::default()) } + #[cfg(feature = "dhcp-ipv4")] + #[tokio::test] + async fn runtime_update_preserves_dhcp_owned_ipv4() { + let mut initial = test_config("dhcp-runtime-update"); + initial.connectivity.runtime.dhcp_ipv4 = true; + let instance = build_instance(initial).unwrap(); + let lease = IpPrefix { + address: "10.126.126.7".parse().unwrap(), + prefix_len: 24, + }; + instance.runtime_config.update_peer_with(|peer| { + peer.runtime.core.routes.ipv4 = Some(lease.clone()); + }); + + let mut replacement = test_config("dhcp-runtime-update"); + replacement.connectivity.runtime.dhcp_ipv4 = true; + instance + .update_runtime_config(runtime_snapshot(&replacement)) + .await + .unwrap(); + + assert_eq!( + instance + .runtime_config + .snapshot() + .peer + .runtime + .core + .routes + .ipv4, + Some(lease) + ); + + let static_replacement = test_config("dhcp-runtime-update"); + instance + .update_runtime_config(runtime_snapshot(&static_replacement)) + .await + .unwrap(); + assert_eq!( + instance + .runtime_config + .snapshot() + .peer + .runtime + .core + .routes + .ipv4, + None + ); + } + #[tokio::test] async fn core_instance_is_a_direct_managed_record() { let instance = build_instance(test_config("managed-directly")).unwrap(); @@ -600,7 +651,7 @@ hostname = "core-owned-config" response.config.unwrap().hostname.as_deref(), Some("patched-in-core") ); - let runtime = instance.runtime_config_snapshot(); + let runtime = instance.runtime_config.snapshot(); assert!(runtime.services.proxy.enable_exit_node); assert!(runtime.services.public_ipv6_provider.provider_supported); assert_eq!(runtime.peer.easytier_version, "host-version"); @@ -685,7 +736,8 @@ hostname = "core-owned-config" ); assert!( instance - .runtime_config_snapshot() + .runtime_config + .snapshot() .services .gateway .port_forwards @@ -694,6 +746,71 @@ hostname = "core-owned-config" instance.stop().await; } + #[cfg(feature = "web-client")] + #[tokio::test] + async fn ignored_gateway_patch_remains_in_toml_config() { + use easytier_proto::{ + api::config::{ConfigPatchAction, InstanceConfigPatch, PortForwardPatch, UrlPatch}, + common::{PortForwardConfigPb, SocketType}, + }; + + let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16); + let toml_config = crate::config::toml::TomlConfig::new_from_str( + "instance_name = \"ignored-gateway-patch\"", + ) + .unwrap(); + let mut host_adapters = adapters(None, Arc::new(packet_sink)); + host_adapters.config.ignore_unsupported_config = true; + host_adapters.config.gateway_enabled = false; + host_adapters.config.endpoint_protocols = vec!["tcp".to_owned(), "udp".to_owned()]; + let instance = CoreInstance::from_toml(toml_config, host_adapters).unwrap(); + instance.start().await.unwrap(); + + crate::management::apply_config_patch( + &instance, + InstanceConfigPatch { + port_forwards: vec![PortForwardPatch { + action: ConfigPatchAction::Add as i32, + cfg: Some(PortForwardConfigPb { + bind_addr: Some( + "127.0.0.1:18080" + .parse::() + .unwrap() + .into(), + ), + dst_addr: Some( + "10.144.144.2:8080" + .parse::() + .unwrap() + .into(), + ), + socket_type: SocketType::Tcp as i32, + }), + }], + connectors: vec![UrlPatch { + action: ConfigPatchAction::Add as i32, + url: Some("quic://127.0.0.1:11010".parse::().unwrap().into()), + }], + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(instance.toml_config().unwrap().get_port_forwards().len(), 1); + assert!( + instance + .runtime_config + .snapshot() + .services + .gateway + .port_forwards + .is_empty() + ); + assert!(instance.list_connectors().is_empty()); + instance.stop().await; + } + #[tokio::test] async fn dropping_core_instance_requests_host_shutdown() { struct DropAwareRuntimeHost(Arc); @@ -992,19 +1109,24 @@ hostname = "core-owned-config" assert!(instances.instances().is_empty()); } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] #[tokio::test] async fn process_management_rpc_owns_instance_create_list_and_delete() { use crate::{ config::toml::TomlConfig, instance::manager::InstanceFactory, - management::{InstanceManager, ProcessManagementRpc, UnsupportedConfigFileStorage}, + management::{ + InstanceManager, ProcessManagementRpc, UnsupportedConfigFileStorage, + register_web_client_rpc, + }, + rpc::service_registry::ServiceRegistry, }; use easytier_proto::{ api::manage::{ DeleteNetworkInstanceRequest, ListNetworkInstanceRequest, NetworkConfig, NetworkingMethod, RunNetworkInstanceRequest, WebClientService, }, + common::RpcDescriptor, rpc_types::controller::BaseController, }; @@ -1038,6 +1160,31 @@ hostname = "core-owned-config" ManagementTestFactory(CoreProcessRuntime::new()), Some(tokio::runtime::Handle::current()), )); + let registry = ServiceRegistry::new(); + register_web_client_rpc( + instances.clone(), + ®istry, + Arc::new(()), + Arc::new(UnsupportedConfigFileStorage), + ); + assert_eq!( + registry.get_method_name(&RpcDescriptor { + domain_name: String::new(), + service_name: "ConfigRpc".to_owned(), + proto_name: "ConfigRpc".to_owned(), + method_index: 2, + }), + Some("get_config".to_owned()) + ); + assert_eq!( + registry.get_method_name(&RpcDescriptor { + domain_name: String::new(), + service_name: "WebClientService".to_owned(), + proto_name: "WebClientService".to_owned(), + method_index: 1, + }), + Some("validate_config".to_owned()) + ); let rpc = ProcessManagementRpc::::new( instances.clone(), Arc::new(()), diff --git a/easytier-core/src/management/full/config_patch.rs b/easytier-core/src/management/full/config_patch.rs index 6dd29ee1..d32545d8 100644 --- a/easytier-core/src/management/full/config_patch.rs +++ b/easytier-core/src/management/full/config_patch.rs @@ -31,7 +31,8 @@ where .toml_config() .ok_or_else(|| anyhow::anyhow!("shared TOML configuration is not available"))?; let candidate = config.detached_snapshot(); - let parsed_prefix = validate_public_ipv6_patch(instance, &config, &patch)?; + let parsed_prefix = + parse_ipv6_public_addr_prefix_patch(patch.ipv6_public_addr_prefix.as_deref())?; let patch_for_host = patch.clone(); // Preserve the existing ordered partial-commit contract: earlier valid @@ -54,8 +55,11 @@ where result?; let result = patch_exit_nodes_config(&candidate, patch.exit_nodes); - validate_and_commit_candidate(instance, &config, &candidate)?; - instance.update_exit_nodes(result?).await; + let normalized = validate_and_commit_candidate(instance, &config, &candidate)?; + result?; + instance + .update_exit_nodes(normalized.peer.exit_nodes.clone()) + .await; let result = patch_mapped_listeners(&candidate, patch.mapped_listeners); validate_and_commit_candidate(instance, &config, &candidate)?; @@ -91,10 +95,11 @@ where candidate.set_ipv6_public_addr_prefix(prefix); provider_config_changed = true; } - validate_and_commit_candidate(instance, &config, &candidate)?; + let normalized = validate_and_commit_candidate(instance, &config, &candidate)?; + let runtime = runtime_config_from_normalized(&normalized); instance .instance_runtime - .synchronize_config(&patch_for_host); + .synchronize_config(&patch_for_host, &runtime); Ok(provider_config_changed) } .await; @@ -106,9 +111,12 @@ where instance .instance_runtime .publish_config_patch(patch_for_host); + #[cfg(feature = "public-ipv6-provider")] if provider_config_changed && instance.state() == CoreInstanceState::Running { instance.reconcile_public_ipv6_provider().await; } + #[cfg(not(feature = "public-ipv6-provider"))] + let _ = provider_config_changed; Ok(()) } @@ -116,14 +124,16 @@ fn validate_and_commit_candidate( instance: &CoreInstance, shared: &TomlConfig, candidate: &TomlConfig, -) -> anyhow::Result<()> +) -> anyhow::Result where H: CoreInstanceHost, { - let runtime = runtime_config_from_toml(instance, candidate)?; + let normalized = CoreInstanceConfig::from_toml_with_host(candidate, instance.host_config())?; + let runtime = runtime_config_from_normalized(&normalized); + runtime.services.public_ipv6_provider.validate()?; instance.validate_runtime_config_capabilities(&runtime)?; shared.replace_from_snapshot(candidate); - Ok(()) + Ok(normalized) } fn runtime_config_from_toml( @@ -134,15 +144,14 @@ where H: CoreInstanceHost, { let normalized = CoreInstanceConfig::from_toml_with_host(config, instance.host_config())?; - let current = instance.runtime_config_snapshot(); - let services = normalized.connectivity.runtime; - let mut peer = normalized.peer.snapshot; - peer.runtime.stun_info = current.peer.runtime.stun_info.clone(); + Ok(runtime_config_from_normalized(&normalized)) +} - Ok(CoreInstanceRuntimeConfig { - services, - peer: Arc::new(peer), - }) +fn runtime_config_from_normalized(config: &CoreInstanceConfig) -> CoreInstanceRuntimeConfig { + CoreInstanceRuntimeConfig { + services: config.connectivity.runtime.clone(), + peer: Arc::new(config.peer.snapshot.clone()), + } } fn parse_ipv6_public_addr_prefix_patch( @@ -160,34 +169,6 @@ fn parse_ipv6_public_addr_prefix_patch( })?))) } -fn validate_public_ipv6_patch( - instance: &CoreInstance, - config: &TomlConfig, - patch: &InstanceConfigPatch, -) -> anyhow::Result>> -where - H: CoreInstanceHost, -{ - let parsed_prefix = - parse_ipv6_public_addr_prefix_patch(patch.ipv6_public_addr_prefix.as_deref())?; - let provider_enabled = patch - .ipv6_public_addr_provider - .unwrap_or(config.get_ipv6_public_addr_provider()); - let configured_prefix = parsed_prefix.unwrap_or_else(|| config.get_ipv6_public_addr_prefix()); - let provider_supported = instance - .runtime_config_snapshot() - .services - .public_ipv6_provider - .provider_supported; - crate::config::peers::PublicIpv6ProviderConfig { - provider_enabled, - configured_prefix, - provider_supported, - } - .validate()?; - Ok(parsed_prefix) -} - fn trace_patchables(patches: &[Patchable]) { for patch in patches { match patch.action { @@ -336,13 +317,25 @@ where H: CoreInstanceHost, { for patch in patches { - let Some(url) = patch.url.map(Into::::into) else { - tracing::warn!("ignored connector patch without URL"); - return Ok(()); - }; match ConfigPatchAction::try_from(patch.action) { - Ok(ConfigPatchAction::Add) => instance.add_connector(url)?, + Ok(ConfigPatchAction::Add) => { + let Some(url) = patch.url.map(Into::::into) else { + tracing::warn!("ignored connector add without URL"); + continue; + }; + if !instance.host_config().accepts_runtime_url(&url) { + continue; + } + instance.add_connector(url)?; + } Ok(ConfigPatchAction::Remove) => { + let Some(url) = patch.url.map(Into::::into) else { + tracing::warn!("ignored connector remove without URL"); + continue; + }; + if !instance.host_config().accepts_runtime_url(&url) { + continue; + } if !instance.remove_connector(&url) { anyhow::bail!("connector not found: {url}"); } diff --git a/easytier-core/src/management/full/instance_info.rs b/easytier-core/src/management/full/instance_info.rs index f73ab476..18797a35 100644 --- a/easytier-core/src/management/full/instance_info.rs +++ b/easytier-core/src/management/full/instance_info.rs @@ -50,7 +50,10 @@ where .map(Into::into) .collect::>(); let peer_route_pairs = list_peer_route_pair(peers.clone(), routes.clone()); + #[cfg(feature = "vpn-portal")] let vpn_portal_cfg = Some(instance.vpn_portal_info().await.client_config); + #[cfg(not(feature = "vpn-portal"))] + let vpn_portal_cfg = Some(String::new()); let dev_name = instance .toml_config() .map(|config| config.get_flags().dev_name) diff --git a/easytier-core/src/management/full/mod.rs b/easytier-core/src/management/full/mod.rs index b5114e0d..66f122ea 100644 --- a/easytier-core/src/management/full/mod.rs +++ b/easytier-core/src/management/full/mod.rs @@ -1,19 +1,24 @@ +#[cfg(feature = "management")] mod compiled; mod config_patch; mod instance_info; +#[cfg(feature = "management")] mod logger_rpc; +#[cfg(feature = "management")] pub(super) mod packet_proxy; mod process_rpc; +#[cfg(feature = "management")] pub mod remote_client; mod web_client; use std::sync::Arc; +#[cfg(any(not(feature = "management"), test))] +use easytier_proto::api::config::ConfigRpcServer; +use easytier_proto::api::manage::WebClientServiceServer; +#[cfg(feature = "management")] use easytier_proto::{ - api::{ - logger::{LoggerRpc, LoggerRpcServer}, - manage::WebClientServiceServer, - }, + api::logger::{LoggerRpc, LoggerRpcServer}, rpc_types::controller::BaseController, }; @@ -30,9 +35,11 @@ use super::{ ConfigFileControl, ConfigFilePermission, DaemonGuard, resolve_optional_instance_by_name, }; +#[cfg(feature = "management")] pub use compiled::register_instance_management_rpc; pub use config_patch::apply_config_patch; pub use instance_info::network_instance_running_info; +#[cfg(feature = "management")] pub use logger_rpc::{ LoggerControl, LoggerManagementRpc, UnsupportedLoggerControl, log_level_name, parse_log_level, }; @@ -44,6 +51,7 @@ pub use process_rpc::{ pub(crate) use web_client::WebClientBackend; pub use web_client::{ConfigServerEndpoint, WebClient, WebClientConfig}; +#[cfg(feature = "management")] pub use super::instance_rpc::full::call_instance_json_rpc; pub fn config_source_from_rpc(source: i32) -> Option { @@ -62,6 +70,7 @@ pub fn config_source_to_rpc(source: ConfigSource) -> i32 { } /// Registers the complete process-level management surface once. +#[cfg(feature = "management")] pub fn register_management_rpc( instances: Arc>, registry: &ServiceRegistry, @@ -81,6 +90,27 @@ pub fn register_management_rpc( ); } +/// Registers the compact reverse-RPC surface required by easytier-web. +#[cfg(any(not(feature = "management"), test))] +pub(crate) fn register_web_client_rpc( + instances: Arc>, + registry: &ServiceRegistry, + hooks: Arc, + storage: Arc, +) where + F: InstanceFactory, CreateContext = ()>, + F::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static, + H: CoreInstanceHost, +{ + let config_rpc = super::instance_rpc::InstanceManagementRpc::::new(instances.clone()); + registry.register(ConfigRpcServer::new(config_rpc), ""); + registry.register( + WebClientServiceServer::new(ProcessManagementRpc::::new(instances, hooks, storage)), + "", + ); +} + +#[cfg(feature = "management")] pub async fn call_management_json_rpc( manager: &Arc>, logger: Arc, diff --git a/easytier-core/src/management/full/web_client.rs b/easytier-core/src/management/full/web_client.rs index 0bdf58e4..ef0e2375 100644 --- a/easytier-core/src/management/full/web_client.rs +++ b/easytier-core/src/management/full/web_client.rs @@ -23,10 +23,11 @@ use crate::{ tunnel::{Tunnel, web_security}, }; -use super::{ - ConfigFileStorage, DaemonGuard, InstanceManager, InstanceMutationHooks, LoggerControl, - register_management_rpc, -}; +#[cfg(not(feature = "management"))] +use super::register_web_client_rpc; +use super::{ConfigFileStorage, DaemonGuard, InstanceManager, InstanceMutationHooks}; +#[cfg(feature = "management")] +use super::{LoggerControl, register_management_rpc}; const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); // Keep retry ownership in this loop when transport or protocol handshakes stall. @@ -108,6 +109,7 @@ where instances: Arc>, hooks: Arc, storage: Arc, + #[cfg(feature = "management")] logger: Arc, } @@ -119,6 +121,7 @@ where H: CoreInstanceHost, { fn register(&self, registry: &ServiceRegistry) { + #[cfg(feature = "management")] register_management_rpc( self.instances.clone(), registry, @@ -126,6 +129,13 @@ where self.storage.clone(), self.logger.clone(), ); + #[cfg(not(feature = "management"))] + register_web_client_rpc( + self.instances.clone(), + registry, + self.hooks.clone(), + self.storage.clone(), + ); } async fn instance_ids(&self) -> anyhow::Result> { @@ -159,13 +169,14 @@ where instances: Arc>, hooks: Arc, storage: Arc, - logger: Arc, + #[cfg(feature = "management")] logger: Arc, ) -> Self { let manager_guard = instances.register_daemon(); let backend = Arc::new(NativeWebClientBackend { instances, hooks, storage, + #[cfg(feature = "management")] logger, }); Self::start(connector, config, backend, Some(manager_guard)) diff --git a/easytier-core/src/management/instance_rpc/config.rs b/easytier-core/src/management/instance_rpc/config.rs new file mode 100644 index 00000000..769c2c9d --- /dev/null +++ b/easytier-core/src/management/instance_rpc/config.rs @@ -0,0 +1,48 @@ +use easytier_proto::{ + api::config::{ + ConfigRpc, GetConfigRequest, GetConfigResponse, PatchConfigRequest, PatchConfigResponse, + }, + rpc_types::{self, controller::BaseController}, +}; + +use crate::{ + config::{api::network_config_from_toml, toml::ConfigLoader as _}, + management::apply_config_patch, +}; + +use super::{ReadOnlyInstanceResolver, ResolvedInstanceManagementRpc}; + +#[async_trait::async_trait] +impl ConfigRpc for ResolvedInstanceManagementRpc +where + R: ReadOnlyInstanceResolver, +{ + type Controller = BaseController; + + async fn patch_config( + &self, + _: BaseController, + request: PatchConfigRequest, + ) -> rpc_types::error::Result { + let instance = self.instance(request.instance.as_ref())?; + if let Some(patch) = request.patch { + apply_config_patch(&instance, patch).await?; + } + Ok(PatchConfigResponse::default()) + } + + async fn get_config( + &self, + _: BaseController, + request: GetConfigRequest, + ) -> rpc_types::error::Result { + let config = self + .instance(request.instance.as_ref())? + .toml_config() + .ok_or_else(|| anyhow::anyhow!("shared TOML configuration is not available"))?; + Ok(GetConfigResponse { + config: Some(network_config_from_toml(&config)), + toml_config: config.dump(), + }) + } +} diff --git a/easytier-core/src/management/instance_rpc/full.rs b/easytier-core/src/management/instance_rpc/full.rs index 8b398d2d..ae8b6d6d 100644 --- a/easytier-core/src/management/instance_rpc/full.rs +++ b/easytier-core/src/management/instance_rpc/full.rs @@ -2,9 +2,7 @@ use std::{sync::Arc, time::Duration}; use easytier_proto::{ api::{ - config::{ - ConfigRpc, GetConfigRequest, GetConfigResponse, PatchConfigRequest, PatchConfigResponse, - }, + config::ConfigRpc, instance::{ AclManageRpc, ConnectorManageRpc, CredentialInfo, CredentialManageRpc, GenerateCredentialRequest, GenerateCredentialResponse, GetAclStatsRequest, @@ -27,7 +25,7 @@ use easytier_proto::{ }; use crate::{ - config::{api::network_config_from_toml, toml::ConfigLoader as _}, + config::toml::ConfigLoader as _, instance::{ CoreInstance, CoreInstanceHost, manager::{InstanceFactory, InstanceManager}, @@ -35,11 +33,8 @@ use crate::{ peers::credential_manager::{CredentialCreateOptions, CredentialInfo as CoreCredentialInfo}, }; -use super::{InstanceManagementRpc, ReadOnlyInstanceResolver, ResolvedInstanceManagementRpc}; -use crate::management::{ - full::{apply_config_patch, packet_proxy}, - resolve_instance, -}; +use super::InstanceManagementRpc; +use crate::management::{full::packet_proxy, resolve_instance}; /// Dispatches the JSON form of an Instance-targeted management RPC without /// introducing a second, Host-owned set of service implementations. @@ -369,38 +364,3 @@ where Err(anyhow::anyhow!("not implemented for management API").into()) } } - -#[async_trait::async_trait] -impl ConfigRpc for ResolvedInstanceManagementRpc -where - R: ReadOnlyInstanceResolver, -{ - type Controller = BaseController; - - async fn patch_config( - &self, - _: BaseController, - request: PatchConfigRequest, - ) -> rpc_types::error::Result { - let instance = self.instance(request.instance.as_ref())?; - if let Some(patch) = request.patch { - apply_config_patch(&instance, patch).await?; - } - Ok(PatchConfigResponse::default()) - } - - async fn get_config( - &self, - _: BaseController, - request: GetConfigRequest, - ) -> rpc_types::error::Result { - let config = self - .instance(request.instance.as_ref())? - .toml_config() - .ok_or_else(|| anyhow::anyhow!("shared TOML configuration is not available"))?; - Ok(GetConfigResponse { - config: Some(network_config_from_toml(&config)), - toml_config: config.dump(), - }) - } -} diff --git a/easytier-core/src/management/instance_rpc/mod.rs b/easytier-core/src/management/instance_rpc/mod.rs index 0e7e6485..9c3feb38 100644 --- a/easytier-core/src/management/instance_rpc/mod.rs +++ b/easytier-core/src/management/instance_rpc/mod.rs @@ -27,6 +27,8 @@ use crate::{ use super::resolve_instance; +#[cfg(feature = "web-client")] +mod config; #[cfg(feature = "management")] pub(super) mod full; #[cfg(all(feature = "management", feature = "proxy-packet"))] diff --git a/easytier-core/src/management/mod.rs b/easytier-core/src/management/mod.rs index f0f5e7d2..76bd3381 100644 --- a/easytier-core/src/management/mod.rs +++ b/easytier-core/src/management/mod.rs @@ -2,7 +2,7 @@ #[cfg(all(feature = "management", any(test, target_os = "wasi")))] mod forwarded_rpc; -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] mod full; mod instance_rpc; mod rpc_server_hook; @@ -28,16 +28,22 @@ pub(crate) use forwarded_rpc::{ }; #[cfg(all(feature = "management", target_os = "wasi"))] pub(crate) use full::WebClientBackend; +#[cfg(all(feature = "web-client", test))] +pub(crate) use full::register_web_client_rpc; #[cfg(feature = "management")] pub use full::remote_client; -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] pub use full::{ ConfigFileStorage, ConfigServerEndpoint, InstanceMutationHooks, InstanceMutationResult, - LoggerControl, LoggerManagementRpc, ProcessManagement, ProcessManagementRpc, - UnsupportedConfigFileStorage, UnsupportedLoggerControl, WebClient, WebClientConfig, - apply_config_patch, call_instance_json_rpc, call_management_json_rpc, config_source_from_rpc, - config_source_to_rpc, log_level_name, network_instance_running_info, parse_log_level, - register_instance_management_rpc, register_management_rpc, + ProcessManagement, ProcessManagementRpc, UnsupportedConfigFileStorage, WebClient, + WebClientConfig, apply_config_patch, config_source_from_rpc, config_source_to_rpc, + network_instance_running_info, +}; +#[cfg(feature = "management")] +pub use full::{ + LoggerControl, LoggerManagementRpc, UnsupportedLoggerControl, call_instance_json_rpc, + call_management_json_rpc, log_level_name, parse_log_level, register_instance_management_rpc, + register_management_rpc, }; pub use instance_rpc::InstanceManagementRpc; pub use rpc_server_hook::ManagementRpcServerHook; diff --git a/easytier-core/src/rpc/packet.rs b/easytier-core/src/rpc/packet.rs index 5649e806..b9ba6234 100644 --- a/easytier-core/src/rpc/packet.rs +++ b/easytier-core/src/rpc/packet.rs @@ -277,12 +277,24 @@ pub fn build_rpc_packet(args: BuildRpcPacketArgs<'_>) -> Vec { ret } -#[cfg(all(test, not(feature = "zstd")))] +#[cfg(test)] mod tests { use crate::proto::common::CompressionAlgoPb; - use super::{accepted_compression_algo, compress_packet}; + use super::accepted_compression_algo; + #[cfg(not(feature = "zstd"))] + use super::compress_packet; + #[test] + fn accepted_compression_matches_build_capabilities() { + #[cfg(feature = "zstd")] + assert_eq!(accepted_compression_algo(), CompressionAlgoPb::Zstd); + + #[cfg(not(feature = "zstd"))] + assert_eq!(accepted_compression_algo(), CompressionAlgoPb::None); + } + + #[cfg(not(feature = "zstd"))] #[tokio::test] async fn compression_negotiation_falls_back_when_zstd_is_unavailable() { assert_eq!(accepted_compression_algo(), CompressionAlgoPb::None); diff --git a/easytier-web/src/client_manager/mod.rs b/easytier-web/src/client_manager/mod.rs index 78eec408..ce5c2a3f 100644 --- a/easytier-web/src/client_manager/mod.rs +++ b/easytier-web/src/client_manager/mod.rs @@ -385,7 +385,10 @@ mod tests { use axum::{Json, Router, extract::State, routing::post}; use easytier::{ common::{MachineIdOptions, config::NetworkConfigExt}, - instance::factory::{NativeInstanceManager, native_instance_manager}, + instance::factory::{ + NativeInstanceManager, native_compact_instance_manager_with_runtime, + native_instance_manager, + }, proto::{ api::manage::{NetworkConfig, NetworkingMethod, PortForwardConfig}, common::CompressionAlgoPb, @@ -901,6 +904,72 @@ mod tests { println!("{:?}", mgr); } + #[tokio::test] + async fn compact_runtime_preserves_unsupported_web_config_during_hot_patch() { + let (webhook_config, webhook_server, _) = test_webhook_config().await; + let mut mgr = ClientManager::new( + Db::memory_db().await, + None, + Duration::ZERO, + Arc::new(FeatureFlags::default()), + webhook_config, + ); + let config_server_addr = add_random_udp_listener(&mut mgr).await; + + let machine_id = uuid::Uuid::new_v4(); + let instance_id = uuid::Uuid::new_v4(); + let core_manager = Arc::new(native_compact_instance_manager_with_runtime( + tokio::runtime::Handle::current(), + )); + let _client = + start_web_client_for_test(config_server_addr, machine_id, core_manager.clone()).await; + let user_id = wait_for_validated_user(&mgr, machine_id).await; + + let desired = updated_managed_network_config(instance_id); + let mut initial = desired.clone(); + initial["port_forwards"] = json!([]); + mgr.reconcile_managed_network_configs( + user_id, + machine_id, + vec![managed_config(instance_id, initial)], + Some("compact-initial".to_string()), + None, + ) + .await + .unwrap(); + wait_for_runtime_config(&core_manager, instance_id, |config| { + config.network_name.as_deref() == Some("managed-updated") + && config.port_forwards.is_empty() + }) + .await; + + mgr.reconcile_managed_network_configs( + user_id, + machine_id, + vec![managed_config(instance_id, desired)], + Some("compact-patched".to_string()), + Some("compact-initial".to_string()), + ) + .await + .unwrap(); + let patched = wait_for_runtime_config(&core_manager, instance_id, |config| { + config.network_name.as_deref() == Some("managed-updated") + && config.port_forwards.len() == 1 + }) + .await; + + assert_updated_runtime_config(&patched, instance_id); + assert_eq!( + mgr.db() + .get_managed_config_revision((user_id, machine_id)) + .await + .unwrap() + .as_deref(), + Some("compact-patched") + ); + webhook_server.abort(); + } + #[tokio::test] async fn managed_web_config_revision_updates_running_core_config() { let (webhook_config, webhook_server, _) = test_webhook_config().await; diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index e55b1cf4..5c162ac6 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -413,14 +413,15 @@ extended-services = [ "proxy-cidr-monitor", ] management = [ + "web-client", "logging", - "management-rpc", "easytier-core/management", "easytier-proto/json-rpc", "easytier-proto/utils", "tokio/full", ] management-rpc = ["easytier-core/management-rpc"] +web-client = ["management-rpc", "easytier-core/web-client"] tcp-hole-punch = ["easytier-core/tcp-hole-punch"] # Deprecated: hotpath profiling has been removed. These feature aliases are # retained as no-ops so existing build scripts using `--features hotpath*` diff --git a/easytier/src/common/global_ctx.rs b/easytier/src/common/global_ctx.rs index ba1bf268..ff1c2130 100644 --- a/easytier/src/common/global_ctx.rs +++ b/easytier/src/common/global_ctx.rs @@ -6,11 +6,14 @@ use std::{ use arc_swap::ArcSwap; use async_trait::async_trait; -use easytier_core::config::PeerId; use easytier_core::connectivity::composite::ConnectorRuntime as _; use easytier_core::peers::public_ipv6::PublicIpv6Host; use easytier_core::socket::{NetNamespace, SocketContext}; use easytier_core::tunnel::effective_encryption_uses_xor; +use easytier_core::{ + config::{PeerId, peers::PeerRuntimeSnapshot, runtime::CoreInstanceRuntimeConfig}, + instance::{CoreInstanceConfig, CoreInstanceHostConfig}, +}; use super::{ config::{ConfigLoader, Flags, NetworkIdentity}, @@ -85,11 +88,13 @@ pub struct GlobalCtx { cached_ipv4: AtomicCell>, cached_ipv6: AtomicCell>, + vpn_portal_cidr: AtomicCell>, hostname: Mutex, tun_device_name: Mutex>, flags: ArcSwap, + runtime_endpoint_protocols: Option>, } impl std::fmt::Debug for GlobalCtx { @@ -113,7 +118,7 @@ impl PublicIpv6Host for GlobalCtx { prefix: cidr::Ipv6Cidr, ) -> HashSet { let context = SocketContext::default() - .with_socket_mark(self.config.get_flags().socket_mark) + .with_socket_mark(self.get_flags().socket_mark) .with_netns(self.net_ns.name().map(NetNamespace::new)); let ip_list = crate::host_runtime::native_host_runtime() .collect_ip_addrs(&context) @@ -139,14 +144,57 @@ impl PublicIpv6Host for GlobalCtx { impl GlobalCtx { pub fn new(config_fs: impl ConfigLoader + 'static) -> Self { + Self::new_inner(config_fs, None, None) + } + + pub(crate) fn new_with_runtime_config( + config_fs: impl ConfigLoader + 'static, + runtime: &CoreInstanceConfig, + host: &CoreInstanceHostConfig, + ) -> Self { + let runtime = CoreInstanceRuntimeConfig { + services: runtime.connectivity.runtime.clone(), + peer: Arc::new(runtime.peer.snapshot.clone()), + }; + let protocols = host.ignore_unsupported_config.then(|| { + host.endpoint_protocols + .iter() + .map(|protocol| protocol.to_ascii_lowercase()) + .collect() + }); + Self::new_inner(config_fs, Some(&runtime), protocols) + } + + fn new_inner( + config_fs: impl ConfigLoader + 'static, + runtime: Option<&CoreInstanceRuntimeConfig>, + runtime_endpoint_protocols: Option>, + ) -> Self { let id = config_fs.get_id(); let network = config_fs.get_network_identity(); let net_ns = NetNS::new(config_fs.get_netns()); - let hostname = match config_fs.get_hostname() { - hostname if !hostname.is_empty() => hostname, - _ => gethostname::gethostname().to_string_lossy().to_string(), - }; - let flags = config_fs.get_flags(); + let hostname = runtime + .and_then(|runtime| runtime.peer.runtime.core.node.hostname.clone()) + .unwrap_or_else(|| match config_fs.get_hostname() { + hostname if !hostname.is_empty() => hostname, + _ => gethostname::gethostname().to_string_lossy().to_string(), + }); + let flags = runtime + .map(|runtime| runtime.peer.flags.clone()) + .unwrap_or_else(|| config_fs.get_flags()); + let ipv4 = runtime + .map(|runtime| Self::runtime_ipv4(&runtime.peer)) + .unwrap_or_else(|| config_fs.get_ipv4()); + let ipv6 = runtime + .map(|runtime| Self::runtime_ipv6(&runtime.peer)) + .unwrap_or_else(|| config_fs.get_ipv6()); + let vpn_portal_cidr = runtime + .map(|runtime| runtime.peer.vpn_portal_cidr) + .unwrap_or_else(|| { + config_fs + .get_vpn_portal_config() + .map(|config| config.client_cidr) + }); if flags.enable_encryption && effective_encryption_uses_xor(&flags.encryption_algorithm) { tracing::warn!("using insecure XOR because no AEAD encryption is configured"); } @@ -160,16 +208,34 @@ impl GlobalCtx { network, event_bus, - cached_ipv4: AtomicCell::new(None), - cached_ipv6: AtomicCell::new(None), + cached_ipv4: AtomicCell::new(ipv4), + cached_ipv6: AtomicCell::new(ipv6), + vpn_portal_cidr: AtomicCell::new(vpn_portal_cidr), hostname: Mutex::new(hostname), tun_device_name: Mutex::new(None), flags: ArcSwap::new(Arc::new(flags)), + runtime_endpoint_protocols, } } + pub(crate) fn runtime_ipv4(peer: &PeerRuntimeSnapshot) -> Option { + let prefix = peer.runtime.core.routes.ipv4.as_ref()?; + let IpAddr::V4(address) = prefix.address else { + return None; + }; + cidr::Ipv4Inet::new(address, prefix.prefix_len).ok() + } + + pub(crate) fn runtime_ipv6(peer: &PeerRuntimeSnapshot) -> Option { + let prefix = peer.runtime.core.routes.ipv6.as_ref()?; + let IpAddr::V6(address) = prefix.address else { + return None; + }; + cidr::Ipv6Inet::new(address, prefix.prefix_len).ok() + } + pub fn subscribe(&self) -> EventBusSubscriber { self.event_bus.subscribe() } @@ -207,31 +273,19 @@ impl GlobalCtx { } pub fn get_ipv4(&self) -> Option { - if let Some(ret) = self.cached_ipv4.load() { - return Some(ret); - } - let addr = self.config.get_ipv4(); - self.cached_ipv4.store(addr); - addr + self.cached_ipv4.load() } pub fn set_ipv4(&self, addr: Option) { - self.config.set_ipv4(addr); - self.cached_ipv4.store(None); + self.cached_ipv4.store(addr); } pub fn get_ipv6(&self) -> Option { - if let Some(ret) = self.cached_ipv6.load() { - return Some(ret); - } - let addr = self.config.get_ipv6(); - self.cached_ipv6.store(addr); - addr + self.cached_ipv6.load() } pub fn set_ipv6(&self, addr: Option) { - self.config.set_ipv6(addr); - self.cached_ipv6.store(None); + self.cached_ipv6.store(addr); } pub fn is_ip_local_ipv6(&self, ip: &std::net::Ipv6Addr) -> bool { @@ -273,7 +327,7 @@ impl GlobalCtx { } pub fn get_vpn_portal_cidr(&self) -> Option { - self.config.get_vpn_portal_config().map(|x| x.client_cidr) + self.vpn_portal_cidr.load() } pub fn get_flags(&self) -> Flags { @@ -281,7 +335,6 @@ impl GlobalCtx { } pub fn set_flags(&self, flags: Flags) { - self.config.set_flags(flags.clone()); self.flags.store(Arc::new(flags)); } @@ -300,6 +353,17 @@ impl GlobalCtx { pub fn no_tun(&self) -> bool { self.flags.load().no_tun } + + pub fn runtime_mapped_listeners(&self) -> Vec { + let listeners = self.config.get_mapped_listeners(); + let Some(protocols) = &self.runtime_endpoint_protocols else { + return listeners; + }; + listeners + .into_iter() + .filter(|listener| protocols.contains(&listener.scheme().to_ascii_lowercase())) + .collect() + } } #[cfg(test)] @@ -377,6 +441,60 @@ pub mod tests { assert!(!config.dump().contains("hostname")); } + #[test] + fn active_dhcp_ipv4_survives_declarative_config_replacement() { + let config = TomlConfigLoader::default(); + config.set_dhcp(true); + let global_ctx = GlobalCtx::new(config.clone()); + let lease = "10.144.144.7/24".parse().unwrap(); + + global_ctx.set_ipv4(Some(lease)); + config.set_ipv4(None); + + assert_eq!(global_ctx.get_ipv4(), Some(lease)); + } + + #[test] + fn runtime_state_does_not_rewrite_toml_config() { + let config = TomlConfigLoader::default(); + let global_ctx = GlobalCtx::new(config.clone()); + let mut runtime_flags = global_ctx.get_flags(); + runtime_flags.enable_exit_node = true; + + global_ctx.set_ipv4(Some("10.144.144.7/24".parse().unwrap())); + global_ctx.set_ipv6(Some("fd00::7/64".parse().unwrap())); + global_ctx.set_flags(runtime_flags); + + assert_eq!(config.get_ipv4(), None); + assert_eq!(config.get_ipv6(), None); + assert!(!config.get_flags().enable_exit_node); + assert_eq!( + global_ctx.get_ipv4(), + Some("10.144.144.7/24".parse().unwrap()) + ); + assert_eq!(global_ctx.get_ipv6(), Some("fd00::7/64".parse().unwrap())); + assert!(global_ctx.get_flags().enable_exit_node); + } + + #[test] + fn compact_runtime_does_not_advertise_unsupported_mapped_listeners() { + let config = TomlConfigLoader::default(); + config.set_mapped_listeners(Some(vec![ + "tcp://127.0.0.1:11010".parse().unwrap(), + "quic://127.0.0.1:11011".parse().unwrap(), + ])); + let host = crate::instance::config::compact_runtime_core_host_config(); + let normalized = + easytier_core::instance::CoreInstanceConfig::from_toml_with_host(&config, &host) + .unwrap(); + + let global_ctx = GlobalCtx::new_with_runtime_config(config.clone(), &normalized, &host); + + assert_eq!(config.get_mapped_listeners().len(), 2); + assert_eq!(global_ctx.runtime_mapped_listeners().len(), 1); + assert_eq!(global_ctx.runtime_mapped_listeners()[0].scheme(), "tcp"); + } + pub fn get_mock_global_ctx_with_network( network_identy: Option, ) -> ArcGlobalCtx { diff --git a/easytier/src/instance/composition.rs b/easytier/src/instance/composition.rs index d307c617..c52f4d51 100644 --- a/easytier/src/instance/composition.rs +++ b/easytier/src/instance/composition.rs @@ -6,15 +6,14 @@ use easytier_core::gateway::proxy::wrapped_transport::WrappedTransportEngines; use easytier_core::gateway::vpn_portal::VpnPortalHost; #[cfg(test)] use easytier_core::host::packet::{HostPacket, PacketSink}; -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] use easytier_core::{ connectivity::manual::ManualTunnelConnector, host::dns::{DnsRecordResolver, DnsResolver}, - instance::CoreInstanceConfig, }; use easytier_core::{ events::{CoreEvent, CoreEventSink}, - instance::{CoreHostAdapters, CoreInstance, PacketEgressHost}, + instance::{CoreHostAdapters, CoreInstance, CoreInstanceConfig, PacketEgressHost}, process_runtime::CoreProcessRuntime, }; @@ -25,7 +24,9 @@ use crate::{ common::global_ctx::ArcGlobalCtx, common::{config::TomlConfig, global_ctx::GlobalCtx}, host_runtime::native_host_runtime, - instance::config::{runtime_core_host_config, runtime_peer_credential_storage}, + instance::config::{ + compact_runtime_core_host_config, runtime_core_host_config, runtime_peer_credential_storage, + }, instance::listeners::RuntimeExternalListenerFactory, instance::runtime_host::NativeInstanceRuntimeHost, }; @@ -42,18 +43,30 @@ use easytier_core::gateway::proxy::wrapped_transport::WrappedTransportEngine; pub(crate) type NativeCoreInstance = CoreInstance; pub(crate) fn compose_native_core_instance( - config: TomlConfig, + toml_config: TomlConfig, process_runtime: Arc, + compact_runtime: bool, ) -> anyhow::Result> { - let global_ctx = Arc::new(GlobalCtx::new(config.clone())); + let host_config = if compact_runtime { + compact_runtime_core_host_config() + } else { + runtime_core_host_config() + }; + let normalized = CoreInstanceConfig::from_toml_with_host(&toml_config, &host_config)?; + let global_ctx = Arc::new(GlobalCtx::new_with_runtime_config( + toml_config.clone(), + &normalized, + &host_config, + )); let runtime_host = NativeInstanceRuntimeHost::new(global_ctx.clone()); - let mut adapters = runtime_core_host_adapters_with_packet_egress( + let mut adapters = runtime_core_host_adapters_with_packet_egress_and_config( global_ctx.clone(), process_runtime, runtime_host.clone(), + host_config, ); adapters.instance_runtime = runtime_host; - NativeCoreInstance::from_toml(config, adapters) + NativeCoreInstance::from_toml(toml_config, adapters) } impl CoreEventSink for GlobalCtx { @@ -139,13 +152,19 @@ impl CoreEventSink for GlobalCtx { } #[cfg(feature = "wrapped-transport")] -fn runtime_wrapped_transport_engines() -> WrappedTransportEngines { +fn runtime_wrapped_transport_engines( + config: &easytier_core::instance::CoreInstanceHostConfig, +) -> WrappedTransportEngines { #[cfg(feature = "kcp")] - let kcp = Some(Arc::new(KcpProxyService::new()) as Arc); + let kcp = config + .kcp_enabled + .then(|| Arc::new(KcpProxyService::new()) as Arc); #[cfg(not(feature = "kcp"))] let kcp = None; #[cfg(feature = "quic")] - let quic = Some(Arc::new(QuicProxyService::new()) as Arc); + let quic = config + .quic_enabled + .then(|| Arc::new(QuicProxyService::new()) as Arc); #[cfg(not(feature = "quic"))] let quic = None; @@ -161,9 +180,10 @@ pub(crate) fn runtime_core_host_adapters( let host = native_instance_host(global_ctx.clone()); let runtime_dns = native_host_runtime(); let adapters = CoreHostAdapters::new(host, runtime_dns, packet_sink, process_runtime); - configure_runtime_core_host_adapters(global_ctx, adapters) + configure_runtime_core_host_adapters(global_ctx, adapters, runtime_core_host_config()) } +#[cfg(test)] pub(crate) fn runtime_core_host_adapters_with_packet_egress( global_ctx: ArcGlobalCtx, process_runtime: Arc, @@ -173,29 +193,45 @@ pub(crate) fn runtime_core_host_adapters_with_packet_egress( let runtime_dns = native_host_runtime(); let adapters = CoreHostAdapters::new_with_packet_egress(host, runtime_dns, packet_egress, process_runtime); - configure_runtime_core_host_adapters(global_ctx, adapters) + configure_runtime_core_host_adapters(global_ctx, adapters, runtime_core_host_config()) +} + +fn runtime_core_host_adapters_with_packet_egress_and_config( + global_ctx: ArcGlobalCtx, + process_runtime: Arc, + packet_egress: Arc, + host_config: easytier_core::instance::CoreInstanceHostConfig, +) -> CoreHostAdapters { + let host = native_instance_host(global_ctx.clone()); + let runtime_dns = native_host_runtime(); + let adapters = + CoreHostAdapters::new_with_packet_egress(host, runtime_dns, packet_egress, process_runtime); + configure_runtime_core_host_adapters(global_ctx, adapters, host_config) } fn configure_runtime_core_host_adapters( global_ctx: ArcGlobalCtx, mut adapters: CoreHostAdapters, + host_config: easytier_core::instance::CoreInstanceHostConfig, ) -> CoreHostAdapters { #[cfg(test)] adapters.replace_stun_provider(Arc::new(crate::common::stun::MockStunInfoCollector { udp_nat_type: crate::proto::common::NatType::Unknown, })); - adapters.config = runtime_core_host_config(); - adapters.credential_storage = runtime_peer_credential_storage(&global_ctx); + adapters.config = host_config.clone(); + adapters.credential_storage = (!host_config.ignore_unsupported_config) + .then(|| runtime_peer_credential_storage(&global_ctx)) + .flatten(); adapters.events = global_ctx.clone(); #[cfg(feature = "wrapped-transport")] { - adapters.wrapped_transports = runtime_wrapped_transport_engines(); + adapters.wrapped_transports = runtime_wrapped_transport_engines(&host_config); } adapters.protocol = Some(runtime_client_protocol_upgrader(global_ctx.clone())); adapters.external_listener_factory = Some(Arc::new(RuntimeExternalListenerFactory)); adapters.server_protocol = Some(runtime_server_protocol_upgrader(global_ctx.clone())); #[cfg(feature = "upnp")] - { + if host_config.upnp_enabled { adapters.udp_hole_punch_platform = Some( crate::instance::udp_hole_punch::runtime_udp_hole_punch_platform( global_ctx.net_ns.clone(), @@ -211,12 +247,12 @@ fn configure_runtime_core_host_adapters( adapters.proxy_cidr_monitor_enabled = true; } #[cfg(feature = "public-ipv6-provider")] - { + if host_config.public_ipv6_provider_supported { adapters.public_ipv6_host = Some(global_ctx.clone()); adapters.public_ipv6_provider = Some(runtime_public_ipv6_provider_platform(&global_ctx)); } #[cfg(feature = "wireguard")] - { + if host_config.vpn_portal_enabled { use crate::common::config::ConfigLoader as _; adapters.vpn_portal = Some(crate::vpn_portal::wireguard::WireGuardPortalHost::new( @@ -230,7 +266,7 @@ fn configure_runtime_core_host_adapters( adapters } -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] pub(crate) fn runtime_one_shot_manual_connector( global_ctx: ArcGlobalCtx, config: &TomlConfig, diff --git a/easytier/src/instance/config.rs b/easytier/src/instance/config.rs index 188ed159..fa4dec9d 100644 --- a/easytier/src/instance/config.rs +++ b/easytier/src/instance/config.rs @@ -44,13 +44,44 @@ pub(crate) fn runtime_core_host_config() -> CoreInstanceHostConfig { all(target_os = "macos", feature = "macos-ne"), target_env = "ohos" ))), - public_ipv6_provider_supported: cfg!(target_os = "linux"), + public_ipv6_provider_supported: cfg!(all( + target_os = "linux", + feature = "public-ipv6-provider" + )), gateway_enabled: cfg!(feature = "socks5"), + proxy_enabled: cfg!(any(feature = "kcp", feature = "quic")), + vpn_portal_enabled: cfg!(feature = "wireguard"), + magic_dns_enabled: cfg!(feature = "magic-dns"), + kcp_enabled: cfg!(feature = "kcp"), + quic_enabled: cfg!(feature = "quic"), + udp_broadcast_enabled: cfg!(all(target_os = "windows", feature = "tun")), + upnp_enabled: cfg!(feature = "upnp"), + tcp_hole_punching_enabled: cfg!(feature = "tcp-hole-punch"), + ignore_unsupported_config: false, easytier_version: EASYTIER_VERSION.to_owned(), endpoint_protocols: IpScheme::VARIANTS.iter().map(ToString::to_string).collect(), } } +pub(crate) fn compact_runtime_core_host_config() -> CoreInstanceHostConfig { + let mut config = runtime_core_host_config(); + config.force_exit_node = false; + config.host_routing.local_exit_node_fallback = false; + config.public_ipv6_provider_supported = false; + config.gateway_enabled = false; + config.proxy_enabled = false; + config.vpn_portal_enabled = false; + config.magic_dns_enabled = false; + config.kcp_enabled = false; + config.quic_enabled = false; + config.udp_broadcast_enabled = false; + config.upnp_enabled = false; + config.tcp_hole_punching_enabled = false; + config.ignore_unsupported_config = true; + config.endpoint_protocols = vec!["tcp".to_owned(), "udp".to_owned()]; + config +} + pub(crate) fn runtime_peer_credential_storage( global_ctx: &ArcGlobalCtx, ) -> Option> { @@ -71,6 +102,9 @@ pub(crate) fn test_core_instance_config( let config = TomlConfig::new_from_str(&global_ctx.config.dump()) .expect("test configuration should round-trip through TOML"); + config.set_ipv4(global_ctx.get_ipv4()); + config.set_ipv6(global_ctx.get_ipv6()); + config.set_flags(global_ctx.get_flags()); let mut host = runtime_core_host_config(); let hostname = global_ctx.get_hostname(); host.hostname_fallback = (!hostname.is_empty()).then_some(hostname); @@ -113,7 +147,7 @@ mod tests { assert_eq!(config.smoltcp_available, cfg!(feature = "smoltcp")); assert_eq!( config.public_ipv6_provider_supported, - cfg!(target_os = "linux") + cfg!(all(target_os = "linux", feature = "public-ipv6-provider")) ); assert_eq!(config.easytier_version, EASYTIER_VERSION); } diff --git a/easytier/src/instance/dns_server/server_instance.rs b/easytier/src/instance/dns_server/server_instance.rs index 494056e4..4c1c3c00 100644 --- a/easytier/src/instance/dns_server/server_instance.rs +++ b/easytier/src/instance/dns_server/server_instance.rs @@ -403,7 +403,7 @@ impl MagicDnsServerInstance { rpc_server.set_hook(data.clone()); // Use configured tld_dns_zone or fall back to DEFAULT_ET_DNS_ZONE if empty - let flags = global_ctx.config.get_flags(); + let flags = global_ctx.get_flags(); let tld_dns_zone_clone = flags.tld_dns_zone.clone(); data.update_dns_records(std::iter::empty(), &tld_dns_zone_clone) diff --git a/easytier/src/instance/factory.rs b/easytier/src/instance/factory.rs index 31e97e48..67dfb98c 100644 --- a/easytier/src/instance/factory.rs +++ b/easytier/src/instance/factory.rs @@ -58,6 +58,19 @@ pub fn native_instance_manager_with_runtime( native_instance_manager_with_optional_runtime(Some(runtime_handle)) } +#[cfg(feature = "management-rpc")] +pub fn native_compact_instance_manager_with_runtime( + runtime_handle: tokio::runtime::Handle, +) -> NativeInstanceManager { + let process_runtime = CoreProcessRuntime::new(); + let factory = NativeInstanceFactory::new(process_runtime) + .with_runtime_handle(Some(runtime_handle.clone())) + .with_compact_runtime(); + #[cfg(feature = "logging")] + let factory = factory.with_cli_event_logging(); + InstanceManager::new(factory, Some(runtime_handle)) +} + #[cfg(feature = "management")] pub fn native_process_management( instances: Arc, @@ -85,7 +98,8 @@ fn native_instance_manager_with_optional_runtime( pub struct NativeInstanceFactory { process_runtime: Arc, runtime_handle: Option, - #[cfg(feature = "management")] + compact_runtime: bool, + #[cfg(feature = "logging")] log_cli_events: bool, } @@ -94,12 +108,13 @@ impl NativeInstanceFactory { Self { process_runtime, runtime_handle: None, - #[cfg(feature = "management")] + compact_runtime: false, + #[cfg(feature = "logging")] log_cli_events: false, } } - #[cfg(feature = "management")] + #[cfg(feature = "logging")] fn with_cli_event_logging(mut self) -> Self { self.log_cli_events = true; self @@ -110,6 +125,11 @@ impl NativeInstanceFactory { self.runtime_handle = runtime_handle; self } + + fn with_compact_runtime(mut self) -> Self { + self.compact_runtime = true; + self + } } impl InstanceFactory for NativeInstanceFactory { @@ -126,8 +146,12 @@ impl InstanceFactory for NativeInstanceFactory { .runtime_handle .as_ref() .map(tokio::runtime::Handle::enter); - let instance = compose_native_core_instance(config, self.process_runtime.clone())?; - #[cfg(feature = "management")] + let instance = compose_native_core_instance( + config, + self.process_runtime.clone(), + self.compact_runtime, + )?; + #[cfg(feature = "logging")] if self.log_cli_events { let events = subscribe_native_instance_event(&instance) .ok_or_else(|| anyhow::anyhow!("native instance runtime host is unavailable"))?; diff --git a/easytier/src/instance/host.rs b/easytier/src/instance/host.rs index f07ce094..5b4b2b53 100644 --- a/easytier/src/instance/host.rs +++ b/easytier/src/instance/host.rs @@ -25,7 +25,7 @@ pub struct NativeInstanceEnvironment { impl NativeInstanceEnvironment { fn new(global_ctx: ArcGlobalCtx, runtime: Arc) -> Self { let socket_context = SocketContext::default() - .with_socket_mark(global_ctx.config.get_flags().socket_mark) + .with_socket_mark(global_ctx.get_flags().socket_mark) .with_netns(global_ctx.net_ns.name().map(NetNamespace::new)); Self { global_ctx, @@ -49,7 +49,7 @@ impl ConnectorEnvironment for NativeInstanceEnvironment { } fn mapped_listeners(&self) -> Vec { - self.global_ctx.config.get_mapped_listeners() + self.global_ctx.runtime_mapped_listeners() } fn is_local_ip(&self, ip: &IpAddr) -> bool { diff --git a/easytier/src/instance/mod.rs b/easytier/src/instance/mod.rs index bc0fe9fa..39dc3200 100644 --- a/easytier/src/instance/mod.rs +++ b/easytier/src/instance/mod.rs @@ -1,4 +1,4 @@ -#[cfg(feature = "management")] +#[cfg(feature = "logging")] pub(crate) mod cli_event_logger; pub(crate) mod composition; pub(crate) mod config; diff --git a/easytier/src/instance/runtime_host.rs b/easytier/src/instance/runtime_host.rs index 06cc668b..3857730c 100644 --- a/easytier/src/instance/runtime_host.rs +++ b/easytier/src/instance/runtime_host.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use easytier_core::{ - gateway::dhcp::DhcpIpv4Host, host::packet::HostPacketReceiver, instance::CorePacketPlane, + config::runtime::CoreInstanceRuntimeConfig, gateway::dhcp::DhcpIpv4Host, + host::packet::HostPacketReceiver, instance::CorePacketPlane, }; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -77,6 +78,41 @@ impl NativeInstanceRuntimeHost { self.event_journal.events() } + #[cfg(feature = "web-client")] + fn synchronize_global_ctx_config( + &self, + patch: &crate::proto::api::config::InstanceConfigPatch, + config: &CoreInstanceRuntimeConfig, + ) { + if patch.hostname.is_some() { + self.global_ctx.set_hostname( + config + .peer + .runtime + .core + .node + .hostname + .clone() + .unwrap_or_default(), + ); + } + if patch.ipv4.is_some() && !config.services.dhcp_ipv4 { + self.global_ctx + .set_ipv4(crate::common::global_ctx::GlobalCtx::runtime_ipv4( + &config.peer, + )); + } + if patch.ipv6.is_some() { + self.global_ctx + .set_ipv6(crate::common::global_ctx::GlobalCtx::runtime_ipv6( + &config.peer, + )); + } + if patch.disable_relay_data.is_some() { + self.global_ctx.set_flags(config.peer.flags.clone()); + } + } + pub(crate) fn subscribe_event(&self) -> crate::common::global_ctx::EventBusSubscriber { self.global_ctx.subscribe() } @@ -98,6 +134,15 @@ mod tests { global_ctx::{GlobalCtx, GlobalCtxEvent}, }; + #[cfg(feature = "web-client")] + fn runtime_config(config: &TomlConfig) -> CoreInstanceRuntimeConfig { + let normalized = easytier_core::instance::CoreInstanceConfig::from_toml(config).unwrap(); + CoreInstanceRuntimeConfig { + services: normalized.connectivity.runtime, + peer: Arc::new(normalized.peer.snapshot), + } + } + #[test] fn runtime_host_owns_event_subscription_context() { let global_ctx = Arc::new(GlobalCtx::new(TomlConfig::default())); @@ -111,4 +156,68 @@ mod tests { GlobalCtxEvent::CredentialChanged ); } + + #[cfg(feature = "web-client")] + #[test] + fn runtime_host_synchronizes_normalized_config_without_management() { + use easytier_core::{config::toml::ConfigLoader as _, instance::InstanceRuntimeHost as _}; + + let config = TomlConfig::default(); + config.set_hostname(Some("before".to_owned())); + config.set_ipv4(Some("10.20.0.1/24".parse().unwrap())); + config.set_ipv6(Some("fd00::1/64".parse().unwrap())); + let global_ctx = Arc::new(GlobalCtx::new(config.clone())); + let runtime_host = NativeInstanceRuntimeHost::new(global_ctx.clone()); + + assert_eq!(global_ctx.get_hostname(), "before"); + assert_eq!(global_ctx.get_ipv4(), Some("10.20.0.1/24".parse().unwrap())); + assert_eq!(global_ctx.get_ipv6(), Some("fd00::1/64".parse().unwrap())); + assert!(!global_ctx.get_flags().disable_relay_data); + + config.set_hostname(Some("after".to_owned())); + config.set_ipv4(Some("10.20.0.2/24".parse().unwrap())); + config.set_ipv6(Some("fd00::2/64".parse().unwrap())); + let mut flags = config.get_flags(); + flags.disable_relay_data = true; + config.set_flags(flags); + runtime_host.synchronize_config( + &crate::proto::api::config::InstanceConfigPatch { + hostname: Some("ignored-raw-hostname".to_owned()), + ipv4: Some("10.99.0.1/24".parse::().unwrap().into()), + ipv6: Some("fd99::1/64".parse::().unwrap().into()), + disable_relay_data: Some(false), + ..Default::default() + }, + &runtime_config(&config), + ); + + assert_eq!(global_ctx.get_hostname(), "after"); + assert_eq!(global_ctx.get_ipv4(), Some("10.20.0.2/24".parse().unwrap())); + assert_eq!(global_ctx.get_ipv6(), Some("fd00::2/64".parse().unwrap())); + assert!(global_ctx.get_flags().disable_relay_data); + } + + #[cfg(feature = "web-client")] + #[test] + fn runtime_host_preserves_dhcp_ipv4_during_config_synchronization() { + use easytier_core::{config::toml::ConfigLoader as _, instance::InstanceRuntimeHost as _}; + + let config = TomlConfig::default(); + config.set_dhcp(true); + let global_ctx = Arc::new(GlobalCtx::new(config.clone())); + let runtime_host = NativeInstanceRuntimeHost::new(global_ctx.clone()); + let lease = "10.20.0.7/24".parse().unwrap(); + global_ctx.set_ipv4(Some(lease)); + + config.set_ipv4(None); + runtime_host.synchronize_config( + &crate::proto::api::config::InstanceConfigPatch { + ipv4: Some("10.99.0.1/24".parse::().unwrap().into()), + ..Default::default() + }, + &runtime_config(&config), + ); + + assert_eq!(global_ctx.get_ipv4(), Some(lease)); + } } diff --git a/easytier/src/instance/runtime_host/event_journal.rs b/easytier/src/instance/runtime_host/event_journal.rs index 5071fec0..3d2814ad 100644 --- a/easytier/src/instance/runtime_host/event_journal.rs +++ b/easytier/src/instance/runtime_host/event_journal.rs @@ -85,28 +85,6 @@ impl EventJournal { self.events.read().unwrap().iter().cloned().collect() } - pub(super) fn synchronize_config( - &self, - patch: &crate::proto::api::config::InstanceConfigPatch, - ) { - if let Some(hostname) = &patch.hostname { - self.global_ctx.set_hostname(hostname.clone()); - } - if let Some(ipv4) = patch.ipv4.as_ref() - && !self.global_ctx.config.get_dhcp() - { - self.global_ctx.set_ipv4(Some((*ipv4).into())); - } - if let Some(ipv6) = patch.ipv6.as_ref() { - self.global_ctx.set_ipv6(Some((*ipv6).into())); - } - if let Some(disable_relay_data) = patch.disable_relay_data { - let mut flags = self.global_ctx.get_flags(); - flags.disable_relay_data = disable_relay_data; - self.global_ctx.set_flags(flags); - } - } - pub(super) fn publish_config_patch( &self, patch: crate::proto::api::config::InstanceConfigPatch, diff --git a/easytier/src/instance/runtime_host/implementation.rs b/easytier/src/instance/runtime_host/implementation.rs index d070330b..9484f3d7 100644 --- a/easytier/src/instance/runtime_host/implementation.rs +++ b/easytier/src/instance/runtime_host/implementation.rs @@ -29,14 +29,21 @@ impl InstanceRuntimeHost for NativeInstanceRuntimeHost { self.management_events_snapshot() } - #[cfg(feature = "management")] - fn synchronize_config(&self, patch: &crate::proto::api::config::InstanceConfigPatch) { - self.event_journal.synchronize_config(patch); + #[cfg(feature = "web-client")] + fn synchronize_config( + &self, + patch: &crate::proto::api::config::InstanceConfigPatch, + config: &easytier_core::config::runtime::CoreInstanceRuntimeConfig, + ) { + self.synchronize_global_ctx_config(patch, config); } - #[cfg(feature = "management")] + #[cfg(feature = "web-client")] fn publish_config_patch(&self, patch: crate::proto::api::config::InstanceConfigPatch) { + #[cfg(feature = "management")] self.event_journal.publish_config_patch(patch); + #[cfg(not(feature = "management"))] + let _ = patch; } fn attach_tun_fd(&self, fd: i32) -> anyhow::Result<()> { diff --git a/easytier/src/instance/runtime_host/magic_dns.rs b/easytier/src/instance/runtime_host/magic_dns.rs index 0b22fed7..8ee17048 100644 --- a/easytier/src/instance/runtime_host/magic_dns.rs +++ b/easytier/src/instance/runtime_host/magic_dns.rs @@ -5,10 +5,7 @@ use tokio_util::{sync::CancellationToken, task::AbortOnDropHandle}; use crate::common::global_ctx::ArcGlobalCtx; #[cfg(feature = "magic-dns")] -use crate::{ - common::config::ConfigLoader as _, - instance::dns_server::{MAGIC_DNS_FAKE_IP, runner::DnsRunner}, -}; +use crate::instance::dns_server::{MAGIC_DNS_FAKE_IP, runner::DnsRunner}; #[derive(Default)] pub(super) struct MagicDnsRuntime { @@ -30,7 +27,7 @@ impl MagicDnsRuntime { tun_dev: Option, tun_ip: Ipv4Inet, ) -> Self { - let active = global_ctx.config.get_flags().accept_dns.then(|| { + let active = global_ctx.get_flags().accept_dns.then(|| { let mut runner = DnsRunner::new( packet_plane, global_ctx, diff --git a/easytier/src/instance/runtime_host/tun_desktop.rs b/easytier/src/instance/runtime_host/tun_desktop.rs index c7343238..94629a31 100644 --- a/easytier/src/instance/runtime_host/tun_desktop.rs +++ b/easytier/src/instance/runtime_host/tun_desktop.rs @@ -17,7 +17,6 @@ use tokio_util::sync::CancellationToken; use super::{MagicDnsRuntime, tun_common::TunNicState}; use crate::{ common::{ - config::ConfigLoader as _, error::Error, global_ctx::{ArcGlobalCtx, GlobalCtxEvent}, }, @@ -133,7 +132,7 @@ impl NativeTunRuntime { pub(super) async fn prepare(&self, packet_plane: Arc) -> anyhow::Result<()> { self.nic.drain().await; - if !self.global_ctx.config.get_flags().no_tun { + if !self.global_ctx.get_flags().no_tun { self.start_static_ip(packet_plane).await?; } Ok(()) diff --git a/easytier/src/instance/virtual_nic.rs b/easytier/src/instance/virtual_nic.rs index a182645b..43c97aa6 100644 --- a/easytier/src/instance/virtual_nic.rs +++ b/easytier/src/instance/virtual_nic.rs @@ -663,7 +663,7 @@ impl VirtualNic { let dev = AsyncDevice::new(dev)?; - let flags = self.global_ctx.config.get_flags(); + let flags = self.global_ctx.get_flags(); let mut mtu_in_config = flags.mtu; if flags.enable_encryption { mtu_in_config -= 20; diff --git a/easytier/src/lib.rs b/easytier/src/lib.rs index 4fe5e823..8a5cc00c 100644 --- a/easytier/src/lib.rs +++ b/easytier/src/lib.rs @@ -20,7 +20,7 @@ pub mod service_manager; pub(crate) mod socket; pub mod tunnel; pub mod utils; -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] pub mod web_client; #[cfg(test)] diff --git a/easytier/src/proto/mod.rs b/easytier/src/proto/mod.rs index 74444c5b..efe71faa 100644 --- a/easytier/src/proto/mod.rs +++ b/easytier/src/proto/mod.rs @@ -1,5 +1,5 @@ pub use easytier_proto::api; -#[cfg(feature = "management")] +#[cfg(feature = "web-client")] pub use easytier_proto::web; pub use easytier_proto::{ ALL_DESCRIPTOR_BYTES, acl, common, core_config, error, peer_rpc, rpc_types, diff --git a/easytier/src/web_client/mod.rs b/easytier/src/web_client/mod.rs index 02699a2a..c8096815 100644 --- a/easytier/src/web_client/mod.rs +++ b/easytier/src/web_client/mod.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; use async_trait::async_trait; use easytier_core::{ + config::toml::ConfigLoader as _, connectivity::{manual::ManualTunnelConnector, protocol::raw::TunnelDialer}, management::{ConfigServerEndpoint, WebClientConfig}, socket::IpVersion, @@ -10,18 +11,21 @@ use easytier_core::{ }; use url::Url; +#[cfg(feature = "management")] +use crate::{ + common::os_info::collect_device_os_info, instance::config_storage::NativeConfigFileStorage, + rpc_service::logger::NativeLoggerControl, +}; use crate::{ common::{ MachineIdOptions, config::TomlConfigLoader, constants::EASYTIER_VERSION, - global_ctx::GlobalCtx, os_info::collect_device_os_info, resolve_machine_id, + global_ctx::GlobalCtx, resolve_machine_id, }, instance::{ composition::runtime_one_shot_manual_connector, - config_storage::NativeConfigFileStorage, factory::{NativeInstanceFactory, NativeInstanceManager}, host::NativeInstanceHost, }, - rpc_service::logger::NativeLoggerControl, tunnel::TunnelScheme, }; @@ -46,23 +50,32 @@ impl WebClient { S: ToString, H: ToString, { - Self { - inner: easytier_core::management::WebClient::new( - connector, - WebClientConfig { - token: token.to_string(), - machine_id, - hostname: hostname.to_string(), - device_os: collect_device_os_info(), - easytier_version: EASYTIER_VERSION.to_owned(), - secure_mode, - }, - manager, - hooks.unwrap_or_else(|| Arc::new(DefaultHooks)), - Arc::new(NativeConfigFileStorage), - Arc::new(NativeLoggerControl), - ), - } + let config = WebClientConfig { + token: token.to_string(), + machine_id, + hostname: hostname.to_string(), + device_os: web_client_device_os_info(), + easytier_version: EASYTIER_VERSION.to_owned(), + secure_mode, + }; + #[cfg(feature = "management")] + let inner = easytier_core::management::WebClient::new( + connector, + config, + manager, + hooks.unwrap_or_else(|| Arc::new(DefaultHooks)), + Arc::new(NativeConfigFileStorage), + Arc::new(NativeLoggerControl), + ); + #[cfg(not(feature = "management"))] + let inner = easytier_core::management::WebClient::new( + connector, + config, + manager, + hooks.unwrap_or_else(|| Arc::new(DefaultHooks)), + Arc::new(easytier_core::management::UnsupportedConfigFileStorage), + ); + Self { inner } } pub fn is_connected(&self) -> bool { @@ -70,6 +83,20 @@ impl WebClient { } } +#[cfg(feature = "management")] +fn web_client_device_os_info() -> easytier_proto::web::DeviceOsInfo { + collect_device_os_info() +} + +#[cfg(not(feature = "management"))] +fn web_client_device_os_info() -> easytier_proto::web::DeviceOsInfo { + easytier_proto::web::DeviceOsInfo { + os_type: std::env::consts::OS.to_owned(), + version: String::new(), + distribution: String::new(), + } +} + pub struct DefaultHooks; #[async_trait] @@ -113,6 +140,7 @@ pub async fn run_web_client( let global_ctx = Arc::new(GlobalCtx::new(config.clone())); let mut flags = global_ctx.get_flags(); flags.bind_device = false; + config.set_flags(flags.clone()); global_ctx.set_flags(flags); let hostname = hostname.unwrap_or_else(|| gethostname::gethostname().to_string_lossy().to_string());