This commit is contained in:
Den Piligrim
2026-03-21 13:24:47 +03:00
parent 21ae964012
commit 5b2870e04a
17 changed files with 386 additions and 152 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: `${location.protocol}//${location.hostname}:3000/api`,
baseURL: `${location.protocol}//${location.hostname}:${location.port}/api`,
});
export default api;
+92 -90
View File
@@ -18,6 +18,7 @@ interface Subscription {
name: string;
uuid: string;
inbounds: any[];
inboundsConfig?: any[];
}
interface Tunnel {
@@ -33,6 +34,7 @@ interface InboundConfigUI {
type: string;
port: string;
sni: string;
link?: string;
}
interface Domain { id: number; name: string; }
@@ -46,6 +48,7 @@ const CONNECTION_OPTIONS = [
'vmess-tcp',
'shadowsocks-tcp',
'trojan-tcp-reality',
'custom',
];
const patchLink = function (link: string, newHost: string): string {
@@ -124,16 +127,16 @@ export default function SubscriptionsPage() {
setEditingId(null);
setName('');
setInbounds([
{ id: generateId(), type: 'hysteria2-udp', port: 'random', sni: 'random' },
{ id: generateId(), type: 'vless-xhttp-reality', port: 'random', sni: 'random' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
{ id: generateId(), type: 'vless-grpc-reality', port: 'random', sni: 'random' },
{ id: generateId(), type: 'vless-ws', port: 'random', sni: 'random' },
{ id: generateId(), type: 'vmess-tcp', port: 'random', sni: 'random' },
{ id: generateId(), type: 'shadowsocks-tcp', port: 'random', sni: 'random' },
{ id: generateId(), type: 'hysteria2-udp', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-xhttp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-grpc-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-ws', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vmess-tcp', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'shadowsocks-tcp', port: 'random', sni: 'random', link: '' },
]);
setPortErrors({});
setOpen(true);
@@ -143,79 +146,58 @@ export default function SubscriptionsPage() {
setEditingId(sub.id);
setName(sub.name);
// Пытаемся маппить существующие инбаунды, или даем дефолтные
if (sub.inbounds && sub.inbounds.length > 0) {
setInbounds(sub.inbounds.map(i => ({
if (sub.inboundsConfig && sub.inboundsConfig.length > 0) {
setInbounds(sub.inboundsConfig.map(i => ({
id: generateId(),
type: i.type || 'vless-tcp-reality',
port: i.port ? i.port.toString() : 'random',
sni: i.sni || 'random'
sni: i.sni || 'random',
link: i.link || ''
})));
} else {
setInbounds([{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' }]);
setInbounds([{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
}
setPortErrors({});
setOpen(true);
};
// Проверка порта
const handlePortCheck = async (portValue: string, id: string) => {
if (portValue === 'random' || portValue.trim() === '') {
setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
return;
}
const portNum = parseInt(portValue);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
setPortErrors(prev => ({ ...prev, [id]: 'Некорректный порт' }));
return;
}
try {
const { data } = await api.get(`/tunnels/check-port/${portNum}`);
if (!data.isFree) {
setPortErrors(prev => ({ ...prev, [id]: 'Порт занят' }));
} else {
setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
}
} catch (e) {
console.error('Ошибка проверки порта', e);
}
};
const handleInboundChange = (id: string, field: keyof InboundConfigUI, value: string) => {
setInbounds(prev => prev.map(inb => inb.id === id ? { ...inb, [field]: value } : inb));
if (field === 'port') {
if (field === 'port' || (field === 'type' && value === 'custom')) {
setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
}
};
const addInbound = () => {
if (inbounds.length < 20) {
setInbounds([...inbounds, { id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' }]);
setInbounds([...inbounds, { id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
}
};
const removeInbound = (id?: string) => {
if (id) {
if (inbounds.length > 1) {
setInbounds(inbounds.filter(inb => inb.id !== id));
setPortErrors(prev => {
const n = { ...prev };
delete n[id];
return n;
});
}
} else {
setInbounds([
{
id: crypto.randomUUID(),
type: 'vless-tcp-reality',
port: 'random',
sni: 'random'
const removeInbound = (id?: string) => {
if (id) {
if (inbounds.length > 1) {
setInbounds(inbounds.filter(inb => inb.id !== id));
setPortErrors(prev => {
const n = { ...prev };
delete n[id];
return n;
});
}
]);
setPortErrors({});
}
};
} else {
setInbounds([
{
id: crypto.randomUUID(),
type: 'vless-tcp-reality',
port: 'random',
sni: 'random',
link: ''
}
]);
setPortErrors({});
}
};
const handleSave = async () => {
if (Object.keys(portErrors).length > 0) {
@@ -229,11 +211,16 @@ const removeInbound = (id?: string) => {
const payload = {
name,
inboundsConfig: inbounds.map(i => ({
type: i.type,
port: i.port === 'random' ? 'random' : parseInt(i.port),
sni: i.sni
}))
inboundsConfig: inbounds.map(i => {
if (i.type === 'custom') {
return { type: i.type, link: i.link };
}
return {
type: i.type,
port: i.port === 'random' ? 'random' : parseInt(i.port),
sni: i.sni
};
})
};
try {
@@ -421,31 +408,46 @@ const removeInbound = (id?: string) => {
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<TextField
size="small"
label="Порт"
placeholder="random или порт"
value={inb.port}
onChange={(e) => handleInboundChange(inb.id, 'port', e.target.value)}
onBlur={(e) => handlePortCheck(e.target.value, inb.id)}
error={!!portErrors[inb.id]}
helperText={portErrors[inb.id] || ""}
sx={{ width: 140 }}
/>
</FormControl>
{inb.type === 'custom' ? (
// Поле для кастомной ссылки
<FormControl size="small" sx={{ flexGrow: 1 }}>
<TextField
size="small"
label="Ссылка на подключение"
placeholder="vless://..."
value={inb.link || ''}
onChange={(e) => handleInboundChange(inb.id, 'link', e.target.value)}
fullWidth
/>
</FormControl>
) : (
<>
<FormControl size="small" sx={{ minWidth: 150 }}>
<TextField
size="small"
label="Порт"
placeholder="random или порт"
value={inb.port}
onChange={(e) => handleInboundChange(inb.id, 'port', e.target.value)}
error={!!portErrors[inb.id]}
helperText={portErrors[inb.id] || ""}
sx={{ width: 140 }}
/>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>SNI</InputLabel>
<Select
value={inb.sni}
label="SNI"
onChange={(e) => handleInboundChange(inb.id, 'sni', e.target.value)}
>
<MenuItem value="random">random</MenuItem>
{domains.map(opt => <MenuItem key={opt.id} value={opt.name}>{opt.name}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>SNI</InputLabel>
<Select
value={inb.sni}
label="SNI"
onChange={(e) => handleInboundChange(inb.id, 'sni', e.target.value)}
>
<MenuItem value="random">random</MenuItem>
{domains.map(opt => <MenuItem key={opt.id} value={opt.name}>{opt.name}</MenuItem>)}
</Select>
</FormControl>
</>
)}
<IconButton
color="primary"
+38 -5
View File
@@ -4,7 +4,11 @@ import {
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions, Chip, CircularProgress,
useTheme,
useMediaQuery
useMediaQuery,
FormControl,
RadioGroup,
FormControlLabel,
Radio
} from '@mui/material';
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
import api from '../api';
@@ -24,9 +28,10 @@ export default function TunnelsPage() {
const [loadingId, setLoadingId] = useState<number | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password');
const [form, setForm] = useState({
name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: ''
name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: ''
});
useEffect(() => { loadTunnels(); }, []);
@@ -39,9 +44,16 @@ export default function TunnelsPage() {
};
const handleCreate = async () => {
await api.post('/tunnels', form);
const payload = {
...form,
password: authMethod === 'password' ? form.password : null,
privateKey: authMethod === 'key' ? form.privateKey : null,
};
await api.post('/tunnels', payload);
setOpen(false);
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: '' });
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: '' });
setAuthMethod('password');
loadTunnels();
};
@@ -149,7 +161,28 @@ export default function TunnelsPage() {
<TextField margin="dense" label="SSH Порт" type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} />
<TextField margin="dense" label="SSH User" fullWidth value={form.username} onChange={handleChange('username')} />
</Box>
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
<FormControl component="fieldset" sx={{ mt: 2, mb: 1 }}>
<RadioGroup row value={authMethod} onChange={(e) => setAuthMethod(e.target.value as 'password' | 'key')}>
<FormControlLabel value="password" control={<Radio />} label="По паролю" />
<FormControlLabel value="key" control={<Radio />} label="По SSH ключу" />
</RadioGroup>
</FormControl>
{authMethod === 'password' ? (
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
) : (
<TextField
margin="dense"
label="SSH Private Key (RSA / Ed25519)"
multiline
rows={4}
fullWidth
value={form.privateKey}
onChange={handleChange('privateKey')}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>