From db289e87bbdbcd8d71c573ec6396b38814562770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=9A=E5=88=9A?= <225284228a@gmail.com> Date: Sat, 10 Oct 2026 04:05:14 +1300 Subject: [PATCH] fix(web): default virtual subnet masks to /24 (#2646) Co-authored-by: 225284228a-droid <239500008+225284228a-droid@users.noreply.github.com> --- easytier-web/frontend-lib/src/locales/cn.yaml | 4 +- easytier-web/frontend-lib/src/locales/en.yaml | 4 +- .../frontend/src/components/NetworkDetail.vue | 4 +- .../frontend/src/components/NetworkList.vue | 4 +- .../frontend/src/modules/virtual-subnet.ts | 4 + .../frontend/tests/dashboard.test.mjs | 55 +++++ easytier-web/src/central_network/service.rs | 192 +++++++++++++++++- 7 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 easytier-web/frontend/src/modules/virtual-subnet.ts diff --git a/easytier-web/frontend-lib/src/locales/cn.yaml b/easytier-web/frontend-lib/src/locales/cn.yaml index fb047bfd..b25ad7ee 100644 --- a/easytier-web/frontend-lib/src/locales/cn.yaml +++ b/easytier-web/frontend-lib/src/locales/cn.yaml @@ -507,8 +507,8 @@ web: virtual_cidr: 虚拟网段 secure_mode: 安全模式 secure_mode_hint: 节点间使用 Noise 加密握手与身份认证;开启后可签发临时凭证,设备实例将重建 - virtual_cidr_hint: 设置后成员将按加入顺序从该网段分配静态 IP(10.x.0.1、.2…);留空则由节点自动协商(DHCP) - virtual_cidr_placeholder: 例如 10.200.0.0/24,留空自动 + virtual_cidr_hint: 未填写子网掩码时默认补上 /24。设置后成员将按加入顺序从该网段分配静态 IP(10.x.0.1、.2…);留空则由节点自动协商(DHCP) + virtual_cidr_placeholder: 例如 10.200.0.0(默认 /24),留空自动 peer_urls_placeholder: 每行一个节点地址,例如 tcp://10.0.0.1:11010 create_hint: 网络密码将自动生成,可在网络详情中查看或重置 gateway_mode: 接入点 diff --git a/easytier-web/frontend-lib/src/locales/en.yaml b/easytier-web/frontend-lib/src/locales/en.yaml index 60fedd4d..d95d5237 100644 --- a/easytier-web/frontend-lib/src/locales/en.yaml +++ b/easytier-web/frontend-lib/src/locales/en.yaml @@ -507,8 +507,8 @@ web: virtual_cidr: Virtual Subnet secure_mode: Secure Mode secure_mode_hint: Noise encrypted handshakes with identity verification; enables temporary credentials. Member instances restart on change - virtual_cidr_hint: When set, members get static IPs assigned from this subnet in join order (.1, .2, ...); leave empty for node-negotiated DHCP - virtual_cidr_placeholder: e.g. 10.200.0.0/24, empty for auto + virtual_cidr_hint: Defaults to /24 when the subnet mask is omitted. When set, members get static IPs assigned from this subnet in join order (.1, .2, ...); leave empty for node-negotiated DHCP + virtual_cidr_placeholder: e.g. 10.200.0.0 (defaults to /24), empty for auto peer_urls_placeholder: One peer URL per line, e.g. tcp://10.0.0.1:11010 create_hint: The network secret is generated automatically and can be viewed or reset in the network detail page gateway_mode: Gateway diff --git a/easytier-web/frontend/src/components/NetworkDetail.vue b/easytier-web/frontend/src/components/NetworkDetail.vue index 69a7020a..b3b41e34 100644 --- a/easytier-web/frontend/src/components/NetworkDetail.vue +++ b/easytier-web/frontend/src/components/NetworkDetail.vue @@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'; import { Config, NetworkTypes, UrlListInput, Utils } from 'easytier-frontend-lib'; import AclPolicyTab from './AclPolicyTab.vue'; import ApiClient, { type CentralNetworkDetail, type CentralNetworkMember, type NetworkCredential, type NodeAclRuleStat, type NodePeerInfo, type NodeRouteInfo, type TemporaryPeer } from '../modules/api'; +import { normalizeVirtualSubnet } from '../modules/virtual-subnet'; const { t } = useI18n() const route = useRoute(); @@ -364,7 +365,7 @@ const saveSettings = async () => { networking_method: method, public_server_url: method === 'PublicServer' ? initialNodes.value[0] : null, peer_urls: method === 'Manual' ? initialNodes.value : [], - virtual_cidr: settingsForm.value.virtual_cidr.trim() || null, + virtual_cidr: normalizeVirtualSubnet(settingsForm.value.virtual_cidr) || null, secure_mode: settingsForm.value.secure_mode, }; await api?.update_network(networkId.value, settings, settingsForm.value.regenerate_secret ? uuidv4() : undefined); @@ -1147,6 +1148,7 @@ const switchTab = async (tab: string) => {
diff --git a/easytier-web/frontend/src/components/NetworkList.vue b/easytier-web/frontend/src/components/NetworkList.vue index 1a34e35e..e7e16dd3 100644 --- a/easytier-web/frontend/src/components/NetworkList.vue +++ b/easytier-web/frontend/src/components/NetworkList.vue @@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { UrlListInput, Utils } from 'easytier-frontend-lib'; import ApiClient, { type CentralNetworkSettings, type CentralNetworkSummary, type GatewayInfo } from '../modules/api'; +import { normalizeVirtualSubnet } from '../modules/virtual-subnet'; const { t } = useI18n() const route = useRoute(); @@ -113,7 +114,7 @@ const openCreate = () => { const createNetwork = async () => { creating.value = true; try { - const virtual_cidr = createForm.value.virtual_cidr.trim() || null; + const virtual_cidr = normalizeVirtualSubnet(createForm.value.virtual_cidr) || null; let settings: CentralNetworkSettings; if (!advancedMode.value && gatewayEnabled.value) { settings = { @@ -218,6 +219,7 @@ const openNetwork = (network: CentralNetworkSummary) => {
diff --git a/easytier-web/frontend/src/modules/virtual-subnet.ts b/easytier-web/frontend/src/modules/virtual-subnet.ts new file mode 100644 index 00000000..bc3d578e --- /dev/null +++ b/easytier-web/frontend/src/modules/virtual-subnet.ts @@ -0,0 +1,4 @@ +export function normalizeVirtualSubnet(value: string): string { + const subnet = value.trim(); + return subnet && !subnet.includes('/') ? `${subnet}/24` : subnet; +} diff --git a/easytier-web/frontend/tests/dashboard.test.mjs b/easytier-web/frontend/tests/dashboard.test.mjs index 6174a0e4..522602e4 100644 --- a/easytier-web/frontend/tests/dashboard.test.mjs +++ b/easytier-web/frontend/tests/dashboard.test.mjs @@ -127,8 +127,12 @@ async function open(t, route = '/h', options = {}, configure = () => {}) { else if (path.includes('/networks/config/')) result = { instance_id: id(100), network_name: 'Engineering', hostname: 'Amsterdam gateway', networking_method: 'Standalone' }; await route.fulfill({ json: result }); }); + const gatewayReady = state.waitForGateway + ? page.waitForResponse(response => response.url().endsWith('/networks/gateway-info')).then(response => response.finished()) + : undefined; await page.goto(`${base}/#${route}`); await page.locator('.console-page').waitFor(); + await gatewayReady; return { page, state, context }; } @@ -239,6 +243,57 @@ test('network creation retains gateway and advanced standalone modes', async t = assert.equal(state.writes.filter(w => w.path === '/networks').at(-1).payload.settings.networking_method, 'Standalone'); }); +for (const [input, expected] of [ + [' 10.200.0.0 ', '10.200.0.0/24'], + [' 10.200.0.0/16 ', '10.200.0.0/16'], + [' ', null], +]) { + for (const gatewayEnabled of [true, false]) { + test(`network creation normalizes virtual subnet ${JSON.stringify(input)} with gateway ${gatewayEnabled}`, async t => { + const { page, state } = await open(t, '/h/networks', {}, state => { + state.gatewayEnabled = gatewayEnabled; + state.waitForGateway = true; + }); + await page.getByRole('button', { name: 'Create Network', exact: true }).click(); + await page.locator('#network-display-name').fill('Subnet test'); + const subnet = page.locator('#network-cidr'); + await subnet.fill(input); + await subnet.press('Tab'); + assert.equal(await subnet.inputValue(), expected ?? ''); + await page.getByRole('dialog').getByRole('button', { name: 'Confirm', exact: true }).click(); + await page.waitForURL('**/networks/created-*'); + const settings = state.writes.find(write => write.path === '/networks').payload.settings; + assert.equal(settings.virtual_cidr, expected); + assert.equal(settings.networking_method, gatewayEnabled ? 'Gateway' : 'Standalone'); + await page.getByRole('tab', { name: 'Settings', exact: true }).click(); + assert.equal(await page.locator('#settings-virtual-cidr').inputValue(), expected ?? ''); + }); + } +} + +test('network settings normalize virtual subnets and preserve explicit masks and DHCP', async t => { + const { page, state } = await open(t, '/h/networks/network-0'); + await page.getByRole('tab', { name: 'Settings', exact: true }).click(); + for (const [input, expected] of [ + [' 10.201.0.0 ', '10.201.0.0/24'], + ['10.201.0.0/20', '10.201.0.0/20'], + [' ', null], + ]) { + const subnet = page.locator('#settings-virtual-cidr'); + await subnet.fill(input); + await subnet.press('Tab'); + assert.equal(await subnet.inputValue(), expected ?? ''); + const saved = page.waitForResponse(response => response.url().endsWith('/networks/network-0') && response.request().method() === 'PATCH'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await saved; + assert.equal(state.writes.filter(write => write.method === 'PATCH').at(-1).payload.settings.virtual_cidr, expected); + await page.getByText('Config Saved', { exact: true }).waitFor(); + await page.reload(); + await page.getByRole('tab', { name: 'Settings', exact: true }).click(); + assert.equal(await subnet.inputValue(), expected ?? ''); + } +}); + test('PublicServer settings retain discovery mode when renamed or edited to the gateway URL', async t => { const { page, state } = await open(t, '/h/networks/network-0', {}, state => { Object.assign(state.networks[0], { diff --git a/easytier-web/src/central_network/service.rs b/easytier-web/src/central_network/service.rs index 551e67ba..30e972d6 100644 --- a/easytier-web/src/central_network/service.rs +++ b/easytier-web/src/central_network/service.rs @@ -82,6 +82,21 @@ pub struct NetworkSettings { pub secure_mode: bool, } +impl NetworkSettings { + fn normalize_virtual_cidr(&mut self) { + self.virtual_cidr = self.virtual_cidr.take().and_then(|value| { + let value = value.trim(); + if value.is_empty() { + None + } else if value.contains('/') { + Some(value.to_owned()) + } else { + Some(format!("{value}/24")) + } + }); + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UpdateNetworkReq { pub settings: NetworkSettings, @@ -370,10 +385,11 @@ impl CentralNetworkService { pub async fn create_network( &self, user_id: UserIdInDb, - settings: NetworkSettings, + mut settings: NetworkSettings, network_secret: Option, ) -> Result { let _mutation = self.mutation_lock.lock().await; + settings.normalize_virtual_cidr(); let network_name = settings .network_name .clone() @@ -423,9 +439,10 @@ impl CentralNetworkService { &self, user_id: UserIdInDb, network_id: Uuid, - request: UpdateNetworkReq, + mut request: UpdateNetworkReq, ) -> Result { let _mutation = self.mutation_lock.lock().await; + request.settings.normalize_virtual_cidr(); let mut intent = self.load(user_id, network_id).await?; let mode = mode_from_settings(&request.settings, self.gateway_config())?; intent.display_name = request.settings.display_name; @@ -1559,6 +1576,177 @@ mod tests { } } + #[tokio::test] + async fn virtual_subnets_are_normalized_on_create_and_update() { + let (service, user_id, _) = service().await; + let existing = service + .create_network(user_id, standalone_settings(), None) + .await + .unwrap(); + let existing_id = Uuid::parse_str(&existing.network_id).unwrap(); + + for (index, (input, expected)) in [ + (Some("10.88.0.0"), Some("10.88.0.0/24")), + (Some(" \t10.88.0.0\n"), Some("10.88.0.0/24")), + (Some(" 10.88.0.0/25 "), Some("10.88.0.0/25")), + (Some("10.88.0.0/16"), Some("10.88.0.0/16")), + (Some(""), None), + (Some(" \t\n"), None), + (None, None), + ] + .into_iter() + .enumerate() + { + let mut settings = standalone_settings(); + settings.network_name = Some(format!("normalized-{index}")); + settings.virtual_cidr = input.map(str::to_owned); + let created = service + .create_network(user_id, settings, None) + .await + .unwrap(); + assert_eq!(created.virtual_cidr.as_deref(), expected); + let created_id = Uuid::parse_str(&created.network_id).unwrap(); + assert_eq!( + service + .get_network(user_id, created_id) + .await + .unwrap() + .virtual_cidr + .as_deref(), + expected + ); + + let mut settings = standalone_settings(); + settings.virtual_cidr = input.map(str::to_owned); + let updated = service + .update_network( + user_id, + existing_id, + UpdateNetworkReq { + settings, + network_secret: None, + }, + ) + .await + .unwrap(); + assert_eq!(updated.virtual_cidr.as_deref(), expected); + assert_eq!( + service + .get_network(user_id, existing_id) + .await + .unwrap() + .virtual_cidr + .as_deref(), + expected + ); + } + } + + #[tokio::test] + async fn virtual_subnets_still_reject_invalid_create_and_update_requests() { + let (service, user_id, _) = service().await; + let network = service + .create_network(user_id, standalone_settings(), None) + .await + .unwrap(); + let id = Uuid::parse_str(&network.network_id).unwrap(); + let before = service.load(user_id, id).await.unwrap(); + + for input in [ + "not-an-ip", + "10.88.0.256", + "10.88.0.0/", + "10.88.0.0/33", + "10.88.0.0/invalid", + "10.88.0.0/24/24", + "fd00::", + ] { + let mut settings = standalone_settings(); + settings.network_name = Some("invalid-subnet".to_owned()); + settings.virtual_cidr = Some(input.to_owned()); + assert!(matches!( + service + .create_network(user_id, settings.clone(), None) + .await, + Err(CentralNetworkServiceError::Invalid(_)) + )); + assert!(matches!( + service + .update_network( + user_id, + id, + UpdateNetworkReq { + settings, + network_secret: None, + }, + ) + .await, + Err(CentralNetworkServiceError::Invalid(_)) + )); + assert_eq!(service.load(user_id, id).await.unwrap(), before); + } + } + + #[tokio::test] + async fn equivalent_virtual_subnet_updates_preserve_automatic_allocations() { + let (service, user_id, _) = service().await; + let devices = [Uuid::new_v4(), Uuid::new_v4()]; + for device in devices { + register_device(&service, user_id, device).await; + } + let network = service + .create_network(user_id, standalone_settings(), None) + .await + .unwrap(); + let id = Uuid::parse_str(&network.network_id).unwrap(); + service + .add_members( + user_id, + id, + AddMembersReq { + device_ids: devices.iter().map(ToString::to_string).collect(), + temporary: false, + ttl_seconds: None, + }, + ) + .await + .unwrap(); + let initial = service.load(user_id, id).await.unwrap(); + let first = initial + .members + .iter() + .find(|member| member.allocated_ipv4.as_deref() == Some("10.88.0.1")) + .unwrap(); + service + .remove_member(user_id, id, Uuid::parse_str(&first.device_id).unwrap()) + .await + .unwrap(); + let before = service.load(user_id, id).await.unwrap(); + assert_eq!(before.members.len(), 1); + assert_eq!( + before.members[0].allocated_ipv4.as_deref(), + Some("10.88.0.2") + ); + + // The free .1 address would replace .2 if an equivalent edit reset allocations. + for input in ["10.88.0.0", " \t10.88.0.0\n", " 10.88.0.0/24 "] { + let mut settings = standalone_settings(); + settings.virtual_cidr = Some(input.to_owned()); + service + .update_network( + user_id, + id, + UpdateNetworkReq { + settings, + network_secret: None, + }, + ) + .await + .unwrap(); + assert_eq!(service.load(user_id, id).await.unwrap(), before); + } + } + #[tokio::test] async fn central_publication_preserves_direct_configs_and_removes_only_old_members() { use easytier::common::config::ConfigSource;