update
This commit is contained in:
+1
-1
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Отмена</Button>
|
||||
|
||||
Reference in New Issue
Block a user