This commit is contained in:
iqubik
2026-03-25 22:28:53 +03:00
parent ed1cd84aba
commit 1b9b9b2431
4 changed files with 403 additions and 153 deletions
+258 -102
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery, Alert, Stack, CircularProgress, Divider, Link as MuiLink, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
import { Delete, Add, UploadFile, Remove, ExpandMore } from '@mui/icons-material';
import { Delete, Add, UploadFile, Remove, ExpandMore, Download } from '@mui/icons-material';
import api from '../api';
interface Domain { id: number; name: string; }
@@ -47,6 +47,66 @@ export default function DomainsPage() {
const [scanPanelExpanded, setScanPanelExpanded] = useState(false);
const [scanStateHydrated, setScanStateHydrated] = useState(false);
const isLoopbackHost = (value: string) => {
const host = value.trim().toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
};
const collectAddrCandidatesFromSettings = (settings: any) => {
const candidates: string[] = [];
const xuiIp = String(settings?.xui_ip || '').trim();
const xuiHost = String(settings?.xui_host || '').trim();
const xuiUrl = String(settings?.xui_url || '').trim();
if (xuiIp) candidates.push(xuiIp);
if (xuiHost) candidates.push(xuiHost);
if (xuiUrl) {
try {
const parsed = new URL(xuiUrl);
if (parsed.hostname) {
candidates.push(parsed.hostname.trim());
}
} catch (_e) {
// Ignore malformed URL from settings and fall back to runtime hostname.
}
}
return candidates.filter(Boolean);
};
const resolveSuggestedScanAddr = async (opts?: { allowLoopbackFallback?: boolean }) => {
const allowLoopbackFallback = Boolean(opts?.allowLoopbackFallback);
let settingsCandidates: string[] = [];
try {
const settingsRes = await api.get('/settings');
settingsCandidates = collectAddrCandidatesFromSettings(settingsRes.data);
const publicFromSettings = settingsCandidates.find((c) => !isLoopbackHost(c));
if (publicFromSettings) {
return publicFromSettings;
}
} catch (e) {
console.error(e);
}
// Fallback: panel host where user opened 3dp (often the target VPS in real usage).
const runtimeHost = window.location.hostname;
if (runtimeHost && !isLoopbackHost(runtimeHost)) {
return runtimeHost;
}
// Optional fallback for explicit reset action: prefer some known address
// over keeping stale user input in the field.
if (allowLoopbackFallback) {
const anyFromSettings = settingsCandidates[0];
if (anyFromSettings) return anyFromSettings;
if (runtimeHost) return runtimeHost;
}
return '';
};
const loadDomains = async () => {
try {
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
@@ -72,9 +132,9 @@ export default function DomainsPage() {
}
try {
const settingsRes = await api.get('/settings');
const defaultAddr = settingsRes.data?.xui_ip || settingsRes.data?.xui_host || '';
const defaultAddr = await resolveSuggestedScanAddr();
if (defaultAddr) {
// Do not overwrite manually saved value from localStorage.
setScanAddr((prev) => (prev.trim() ? prev : defaultAddr));
}
} catch (e) {
@@ -86,6 +146,7 @@ export default function DomainsPage() {
}, []);
useEffect(() => {
// Hydrate scanner UI state once so users do not lose pre-import review list after reload.
try {
const raw = localStorage.getItem(SCAN_STORAGE_KEY);
if (!raw) return;
@@ -118,6 +179,7 @@ export default function DomainsPage() {
if (!scanStateHydrated) return;
try {
// Persist scanner input + results + accordion state for continuation after F5.
localStorage.setItem(
SCAN_STORAGE_KEY,
JSON.stringify({
@@ -234,35 +296,76 @@ export default function DomainsPage() {
setScanCandidates((prev) => prev.filter((d) => d !== domain));
};
const handleClearScannedDomains = () => {
const handleClearScannedDomains = async () => {
setScanCandidates([]);
setScanResult(null);
setScanAddr('');
const suggestedAddr = await resolveSuggestedScanAddr({ allowLoopbackFallback: true });
setScanAddr(suggestedAddr);
};
const downloadDomainsAsTxt = (filename: string, domainNames: string[]) => {
const content = `${domainNames.join('\n')}\n`;
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const getExportTimestamp = () => new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const handleExportScannedDomains = () => {
if (scanCandidates.length === 0) return;
downloadDomainsAsTxt(`sni-scanned-${getExportTimestamp()}.txt`, scanCandidates);
};
const handleExportMainDomains = async () => {
if (domains.length === 0) return;
try {
const { data } = await api.get('/domains/all');
const names = (Array.isArray(data) ? data : [])
.map((d: Domain) => d.name)
.filter(Boolean);
if (names.length === 0) return;
downloadDomainsAsTxt(`sni-whitelist-${getExportTimestamp()}.txt`, names);
} catch (_e) {
alert('Ошибка экспорта списка');
}
};
return (
<Box>
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Белый список доменов (SNI)</Typography>
<Paper sx={{ mb: 2 }}>
<Accordion
expanded={scanPanelExpanded}
onChange={(_event, expanded) => setScanPanelExpanded(expanded)}
disableGutters
sx={{
boxShadow: 'none',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant='h6'>Автопоиск SNI (backend scanner)</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pb: 2 }}>
{scanCapabilities?.scannerAvailable && (
<Paper sx={{ mb: 2 }}>
<Accordion
expanded={scanPanelExpanded}
onChange={(_event, expanded) => setScanPanelExpanded(expanded)}
disableGutters
sx={{
boxShadow: 'none',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant='h6'>Автопоиск SNI (backend scanner)</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pb: 2 }}>
{scanCapabilities && (!scanCapabilities.scannerAvailable || !scanCapabilities.timeoutAvailable) && (
<Alert severity='warning' sx={{ mb: 2 }}>
Сканер в контейнере недоступен. scanner: {String(scanCapabilities.scannerAvailable)}, timeout: {String(scanCapabilities.timeoutAvailable)}
</Alert>
)}
{scanCapabilities && (!scanCapabilities.scannerAvailable || !scanCapabilities.timeoutAvailable) && (
<Alert severity='warning' sx={{ mb: 2 }}>
Сканер в контейнере недоступен. scanner: {String(scanCapabilities.scannerAvailable)}, timeout: {String(scanCapabilities.timeoutAvailable)}
</Alert>
)}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
<TextField
@@ -289,7 +392,7 @@ export default function DomainsPage() {
sx={{ minWidth: 120 }}
/>
<TextField
label='Таймаут'
label='Таймаут, сек'
type='number'
value={scanTimeout}
onChange={(e) => setScanTimeout(Number(e.target.value))}
@@ -309,6 +412,16 @@ export default function DomainsPage() {
>
Добавить найденные в список
</Button>
{scanCandidates.length > 0 && (
<Button
variant='outlined'
startIcon={<Download />}
onClick={handleExportScannedDomains}
disabled={isScanning}
>
Экспорт найденных
</Button>
)}
<Button
variant='text'
color='error'
@@ -322,15 +435,25 @@ export default function DomainsPage() {
{scanError && <Alert severity='error' sx={{ mt: 2 }}>{scanError}</Alert>}
{scanResult && (
<Box sx={{ mt: 2 }}>
{scanResult && (
<Box sx={{ mt: 2 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant='body2' sx={{ mb: 1 }}>
Найдено: <b>{scanResult.foundCount}</b>. К отбору: <b>{scanCandidates.length}</b>. Код выхода: <b>{scanResult.exitCode}</b>. Таймаут: <b>{String(scanResult.timedOut)}</b>
Найдено доменов: <b>{scanResult.foundCount}</b>. В предварительном списке: <b>{scanCandidates.length}</b>.
</Typography>
<Typography
variant='body2'
color={scanResult.timedOut ? 'info.main' : 'success.main'}
sx={{ mb: 1 }}
>
{scanResult.timedOut
? `Скан остановлен по лимиту времени (${scanResult.scanSeconds} сек) - это нормальный режим поиска.`
: 'Скан завершен успешно.'}
{' '}<Box component='span' sx={{ color: 'text.secondary' }}>(код: {scanResult.exitCode})</Box>
</Typography>
<Typography variant='body2' color='text.secondary' sx={{ mb: 1 }}>
Проверяйте домены кликом и удаляйте лишние перед импортом.
</Typography>
<Alert severity='info' sx={{ mb: 1 }}>
Можно открыть домен в новом окне, проверить вручную и удалить из предварительного списка перед импортом.
</Alert>
<Paper variant='outlined' sx={{ maxHeight: 220, overflow: 'auto' }}>
<List dense>
{scanCandidates.map((d) => (
@@ -365,80 +488,113 @@ export default function DomainsPage() {
)}
</List>
</Paper>
</Box>
)}
</AccordionDetails>
</Accordion>
</Paper>
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
<TextField
label="Доменное имя" size="small" fullWidth
value={newDomain} onChange={(e) => setNewDomain(e.target.value)}
/>
{isMobile ? (
<>
<IconButton edge="end" onClick={() => fileInputRef.current?.click()}><UploadFile /></IconButton>
<IconButton edge="end" onClick={handleAdd}><Add /></IconButton>
</>
) : (
<>
<Button
variant="outlined"
startIcon={<UploadFile />}
sx={{ width: isMobile ? 'auto' : '170px' }}
onClick={() => fileInputRef.current?.click()}
>
{isMobile ? '' : 'Из файла'}
</Button>
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
</>
)}
<input
type="file"
accept=".txt"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
</Paper>
{domains.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'end', width: '100%' }}>
<Button
variant="text"
color="error"
size='small'
startIcon={<Remove />}
onClick={handleDeleteAll}
>
Удалить все
</Button>
</Box>
</Box>
)}
</AccordionDetails>
</Accordion>
</Paper>
)}
<Paper sx={{ mt: domains.length > 0 ? 0 : 3 }}>
<List>
{domains.map((d) => (
<ListItem key={d.id} secondaryAction={
<IconButton edge="end" onClick={() => handleDelete(d.id)}><Delete /></IconButton>
}>
<ListItemText primary={d.name} />
</ListItem>
))}
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет доменов</Typography>}
</List>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="Доменов на странице:"
labelDisplayedRows={({ from, to, count }) => `${from}${to} из ${count !== -1 ? count : `более ${to}`}`}
/>
<Paper sx={{ p: 2 }}>
<Typography variant='h6' gutterBottom>
Управление белым списком (SNI)
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
label="Доменное имя"
size="small"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
sx={{ flex: '1 1 280px' }}
/>
{isMobile ? (
<>
<IconButton edge="end" onClick={() => fileInputRef.current?.click()}><UploadFile /></IconButton>
<IconButton edge="end" onClick={handleAdd}><Add /></IconButton>
</>
) : (
<>
<Button
variant="outlined"
startIcon={<UploadFile />}
sx={{ width: '170px' }}
onClick={() => fileInputRef.current?.click()}
>
Из файла
</Button>
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
</>
)}
<input
type="file"
accept=".txt"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
</Box>
{domains.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'end', width: '100%', mt: 1, gap: 1, flexWrap: 'wrap' }}>
<Button
variant="outlined"
size='small'
startIcon={<Download />}
onClick={handleExportMainDomains}
>
Экспорт списка
</Button>
<Button
variant="text"
color="error"
size='small'
startIcon={<Remove />}
onClick={handleDeleteAll}
>
Удалить все
</Button>
</Box>
)}
<Paper variant='outlined' sx={{ mt: 1 }}>
<List>
{domains.map((d) => (
<ListItem
key={d.id}
sx={{
borderRadius: 1,
transition: 'background-color 120ms ease',
'&:hover': {
backgroundColor: 'action.hover',
},
}}
secondaryAction={
<IconButton edge="end" onClick={() => handleDelete(d.id)}><Delete /></IconButton>
}
>
<ListItemText
primary={
<MuiLink href={`https://${d.name}`} target='_blank' rel='noopener noreferrer' underline='hover'>
{d.name}
</MuiLink>
}
/>
</ListItem>
))}
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет доменов</Typography>}
</List>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="Доменов на странице:"
labelDisplayedRows={({ from, to, count }) => `${from}${to} из ${count !== -1 ? count : `более ${to}`}`}
/>
</Paper>
</Paper>
</Box>
);
+6 -2
View File
@@ -13,14 +13,18 @@ RUN npm run build
FROM golang:1.23-alpine AS scanner-builder
ARG REALITLSCANNER_REPO=https://github.com/XTLS/RealiTLScanner.git
ARG REALITLSCANNER_REF=main
# Pin to an immutable commit for reproducible builds (main as of 2026-03-25).
ARG REALITLSCANNER_REF=4dbba8cb1d7c6be86b260dd45db7fd2a84d3293b
ARG TARGETOS=linux
ARG TARGETARCH=amd64
WORKDIR /src
RUN apk add --no-cache git
RUN git clone --depth 1 --branch ${REALITLSCANNER_REF} ${REALITLSCANNER_REPO} .
RUN git init . \
&& git remote add origin ${REALITLSCANNER_REPO} \
&& git fetch --depth 1 origin ${REALITLSCANNER_REF} \
&& git checkout --detach FETCH_HEAD
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/RealiTLScanner-linux-64 .
FROM node:24-alpine
+75 -41
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
import { BadRequestException, HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
import { spawn, spawnSync } from 'child_process';
type StartScanPayload = {
@@ -12,6 +12,8 @@ type StartScanPayload = {
export class DomainScannerService {
private readonly logger = new Logger(DomainScannerService.name);
private readonly scannerBin = 'RealiTLScanner-linux-64';
private isScanRunning = false;
private readonly logTailLimit = 8000;
getCapabilities() {
const scannerCheck = spawnSync('sh', ['-lc', `command -v ${this.scannerBin}`], { encoding: 'utf-8' });
@@ -26,13 +28,19 @@ export class DomainScannerService {
}
async startScan(payload: StartScanPayload) {
// Scanner is CPU/network heavy; keep exactly one active run per backend instance
// to avoid accidental DoS from repeated button clicks.
if (this.isScanRunning) {
throw new HttpException('Сканер уже запущен, дождитесь завершения текущего запуска', HttpStatus.TOO_MANY_REQUESTS);
}
const addr = (payload.addr || '').trim();
if (!addr) {
throw new BadRequestException('Поле addr обязательно');
}
const scanSeconds = this.clampNumber(payload.scanSeconds, 120, 10, 600);
const thread = this.clampNumber(payload.thread, 2, 1, 50);
const thread = this.clampNumber(payload.thread, 2, 1, 20);
const connectTimeout = this.clampNumber(payload.timeout, 5, 1, 20);
const capabilities = this.getCapabilities();
@@ -59,51 +67,69 @@ export class DomainScannerService {
this.logger.log(`Starting scanner: addr=${addr}, seconds=${scanSeconds}, thread=${thread}, timeout=${connectTimeout}`);
const child = spawn('timeout', args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.isScanRunning = true;
try {
const child = spawn('timeout', args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
const domains = new Set<string>();
let stdout = '';
let stderr = '';
const domains = new Set<string>();
let stdout = '';
let stderr = '';
let stdoutRemainder = '';
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stdout += text;
this.extractDomainsFromLog(text, domains);
});
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stdout = this.appendTail(stdout, text);
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
// Keep unfinished line tail between chunks; this prevents losing domains
// when "cert-domain=..." is split by stream chunk boundaries.
const combined = stdoutRemainder + text;
const parts = combined.split(/\r?\n/);
stdoutRemainder = parts.pop() ?? '';
for (const line of parts) {
this.extractDomainsFromLog(line, domains);
}
});
const exitCode = await new Promise<number>((resolve, reject) => {
child.on('error', reject);
child.on('close', (code) => resolve(code ?? -1));
}).catch((error: NodeJS.ErrnoException) => {
this.logger.error(`Scanner process failed to start: ${error.message}`);
throw new ServiceUnavailableException(`Не удалось запустить сканер: ${error.message}`);
});
child.stderr.on('data', (chunk: Buffer) => {
stderr = this.appendTail(stderr, chunk.toString());
});
const timedOut = exitCode === 124 || exitCode === 137 || exitCode === 143;
if (exitCode !== 0 && !timedOut) {
this.logger.error(`Scanner failed, code=${exitCode}, stderr=${stderr.slice(-1200)}`);
throw new InternalServerErrorException(`Сканер завершился с ошибкой (code=${exitCode})`);
const exitCode = await new Promise<number>((resolve, reject) => {
child.on('error', reject);
child.on('close', (code) => resolve(code ?? -1));
}).catch((error: NodeJS.ErrnoException) => {
this.logger.error(`Scanner process failed to start: ${error.message}`);
throw new ServiceUnavailableException(`Не удалось запустить сканер: ${error.message}`);
});
if (stdoutRemainder) {
this.extractDomainsFromLog(stdoutRemainder, domains);
}
const timedOut = exitCode === 124 || exitCode === 137 || exitCode === 143;
if (exitCode !== 0 && !timedOut) {
this.logger.error(`Scanner failed, code=${exitCode}, stderr=${stderr.slice(-1200)}`);
throw new InternalServerErrorException(`Сканер завершился с ошибкой (code=${exitCode})`);
}
const sortedDomains = [...domains].sort();
return {
addr,
scanSeconds,
thread,
timeout: connectTimeout,
timedOut,
exitCode,
foundCount: sortedDomains.length,
domains: sortedDomains,
stderrTail: stderr.slice(-800),
stdoutTail: stdout.slice(-800),
};
} finally {
this.isScanRunning = false;
}
const sortedDomains = [...domains].sort();
return {
addr,
scanSeconds,
thread,
timeout: connectTimeout,
timedOut,
exitCode,
foundCount: sortedDomains.length,
domains: sortedDomains,
stderrTail: stderr.slice(-800),
stdoutTail: stdout.slice(-800),
};
}
private extractDomainsFromLog(text: string, out: Set<string>) {
@@ -140,4 +166,12 @@ export class DomainScannerService {
if (num > max) return max;
return Math.floor(num);
}
private appendTail(current: string, incoming: string) {
const merged = current + incoming;
if (merged.length <= this.logTailLimit) {
return merged;
}
return merged.slice(-this.logTailLimit);
}
}
+64 -8
View File
@@ -1,4 +1,4 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Domain } from './entities/domain.entity';
@@ -38,10 +38,18 @@ export class DomainsService implements OnModuleInit {
}
async create(createDomainDto: { name: string }) {
const exists = await this.repo.findOne({ where: { name: createDomainDto.name } });
const normalized = this.normalizeImportedDomain(createDomainDto.name);
if (!normalized) {
throw new BadRequestException('Некорректное доменное имя');
}
const exists = await this.repo
.createQueryBuilder('domain')
.where('LOWER(domain.name) = LOWER(:name)', { name: normalized })
.getOne();
if (exists) return exists;
const domain = this.repo.create(createDomainDto);
const domain = this.repo.create({ name: normalized });
return this.repo.save(domain);
}
@@ -81,14 +89,14 @@ export class DomainsService implements OnModuleInit {
if (!names || names.length === 0) return { count: 0 };
const cleanNames = names
.map(n => n.trim())
.filter(n => n.length > 0);
.map((name) => this.normalizeImportedDomain(name))
.filter((name): name is string => Boolean(name));
const existing = await this.repo.find();
const existingSet = new Set(existing.map(d => d.name));
const existingSet = new Set(existing.map(d => d.name.toLowerCase()));
const uniqueNewNames = [...new Set(cleanNames)]
.filter(name => !existingSet.has(name));
.filter(name => !existingSet.has(name.toLowerCase()));
if (uniqueNewNames.length === 0) return { count: 0 };
@@ -97,4 +105,52 @@ export class DomainsService implements OnModuleInit {
return { count: entities.length };
}
}
private normalizeImportedDomain(input: string) {
let value = (input || '').replace(/^\uFEFF/, '').trim();
if (!value) return null;
// Skip full-line comments often used in shared lists.
if (/^(#|;|\/\/)/.test(value)) {
return null;
}
// Remove inline comments while keeping the domain token itself.
value = value.replace(/\s+(#|;|\/\/).*$/, '').trim();
if (!value) return null;
value = value
.replace(/^['"`]+|['"`]+$/g, '')
.replace(/^[a-z]+:\/\//i, '')
.split('/')[0]
.split('?')[0]
.split('#')[0]
.trim()
.toLowerCase();
const hostPortMatch = value.match(/^(.+):(\d{1,5})$/);
if (hostPortMatch) {
value = hostPortMatch[1];
}
// Wildcard entries are valid for input UX, but in whitelist storage we keep root form.
value = value.replace(/^\*+\./, '').replace(/^\.+/, '').replace(/\.+$/, '');
if (!value) return null;
return this.isValidDomain(value) ? value : null;
}
private isValidDomain(domain: string) {
if (domain.length > 253) return false;
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(domain)) return false;
const parts = domain.split('.');
if (parts.length < 2) return false;
return parts.every((part) =>
/^[a-z0-9-]{1,63}$/.test(part)
&& !part.startsWith('-')
&& !part.endsWith('-'),
);
}
}