fix(web): default virtual subnet masks to /24 (#2646)

Co-authored-by: 225284228a-droid <239500008+225284228a-droid@users.noreply.github.com>
This commit is contained in:
刚刚and225284228a-droid authored and GitHub committed 2026-10-09 23:05:14 +08:00
1 parent 2583d867c3
commit db289e87bb
7 files changed
+259 -8

No files matched your search

@@ -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: 接入点
@@ -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
@@ -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) => {
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('web.network_list.virtual_cidr_hint')"></span>
</div>
<InputText id="settings-virtual-cidr" v-model="settingsForm.virtual_cidr" class="font-mono"
@blur="settingsForm.virtual_cidr = normalizeVirtualSubnet(settingsForm.virtual_cidr)"
:placeholder="t('web.network_list.virtual_cidr_placeholder')" />
</div>
<div class="flex flex-col gap-1">
@@ -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) => {
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('web.network_list.virtual_cidr_hint')"></span>
</div>
<InputText id="network-cidr" v-model="createForm.virtual_cidr" class="font-mono"
@blur="createForm.virtual_cidr = normalizeVirtualSubnet(createForm.virtual_cidr)"
:placeholder="t('web.network_list.virtual_cidr_placeholder')" />
</div>
<div class="flex items-center gap-2">
@@ -0,0 +1,4 @@
export function normalizeVirtualSubnet(value: string): string {
const subnet = value.trim();
return subnet && !subnet.includes('/') ? `${subnet}/24` : subnet;
}
@@ -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], {
+190 -2
View File
@@ -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<String>,
) -> Result<NetworkDetail, CentralNetworkServiceError> {
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<NetworkDetail, CentralNetworkServiceError> {
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;