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;