mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-09 20:26:15 -08:00
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:
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], {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user