ваше сообщение коммита
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -141,11 +141,33 @@ services:
|
|||||||
curl -X POST http://ollama:11434/api/pull -d '{\"name\":\"${OLLAMA_MODEL:-qwen2.5:7b}\"}' -H 'Content-Type: application/json'
|
curl -X POST http://ollama:11434/api/pull -d '{\"name\":\"${OLLAMA_MODEL:-qwen2.5:7b}\"}' -H 'Content-Type: application/json'
|
||||||
echo 'Done!'
|
echo 'Done!'
|
||||||
"
|
"
|
||||||
|
ssh-tunnel-frontend:
|
||||||
|
image: alpine:latest
|
||||||
|
container_name: ssh-tunnel-frontend
|
||||||
|
volumes:
|
||||||
|
- ./id_rsa:/key:ro
|
||||||
|
command: >
|
||||||
|
sh -c "apk add --no-cache openssh && ssh -i /key -o StrictHostKeyChecking=no -N -R 0.0.0.0:9000:host.docker.internal:5173 root@185.221.214.140"
|
||||||
|
restart: unless-stopped
|
||||||
|
# network_mode: host <-- Эту строку удаляем
|
||||||
|
extra_hosts: # <-- Эту секцию добавляем
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
ssh-tunnel-backend:
|
||||||
|
image: alpine:latest
|
||||||
|
container_name: ssh-tunnel-backend
|
||||||
|
volumes:
|
||||||
|
- ./id_rsa:/key:ro
|
||||||
|
command: >
|
||||||
|
sh -c "apk add --no-cache openssh && ssh -i /key -o StrictHostKeyChecking=no -N -R 0.0.0.0:8000:host.docker.internal:8000 root@185.221.214.140"
|
||||||
|
restart: unless-stopped
|
||||||
|
# network_mode: host <-- Эту строку удаляем
|
||||||
|
extra_hosts: # <-- Эту секцию добавляем
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data: null
|
postgres_data: null
|
||||||
ollama_data: null
|
ollama_data: null
|
||||||
backend_node_modules: null
|
backend_node_modules: null
|
||||||
frontend_node_modules: null
|
frontend_node_modules: null
|
||||||
vector_search_data: null
|
vector_search_data: null
|
||||||
80
frontend/nginx.conf
Normal file
80
frontend/nginx.conf
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Оптимизация производительности
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# Сжатие
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Кеширование статических файлов
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header Vary Accept-Encoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Основные файлы HTML
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header Expires "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API прокси (если нужно)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket прокси
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Безопасность
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,8 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
// Определяем baseURL в зависимости от окружения
|
|
||||||
const getBaseUrl = () => {
|
|
||||||
// В браузере используем localhost
|
|
||||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
|
||||||
return 'http://localhost:8000';
|
|
||||||
}
|
|
||||||
// В других случаях используем переменную окружения
|
|
||||||
return import.meta.env.VITE_API_URL || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Создаем экземпляр axios с базовым URL
|
// Создаем экземпляр axios с базовым URL
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: getBaseUrl(),
|
baseURL: '/api',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -29,17 +19,27 @@ api.interceptors.request.use(
|
|||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Добавляем перехватчик для обработки ответов
|
// Добавляем перехватчик ответов для обработки ошибок
|
||||||
api.interceptors.response.use(
|
axios.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
console.log('Response from server:', response.data);
|
// Проверяем, что ответ действительно JSON
|
||||||
|
if (response.headers['content-type'] &&
|
||||||
|
!response.headers['content-type'].includes('application/json')) {
|
||||||
|
console.warn('Server returned non-JSON response:', response.headers['content-type']);
|
||||||
|
// Если это HTML, значит, запрос ушёл не туда
|
||||||
|
if (response.headers['content-type'].includes('text/html')) {
|
||||||
|
throw new Error('Server returned HTML instead of JSON. Check API endpoint.');
|
||||||
|
}
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
async (error) => {
|
(error) => {
|
||||||
// Проверяем, что это действительно ошибка авторизации
|
// Если ошибка содержит HTML в response
|
||||||
if (error.response?.status === 401) {
|
if (error.response && error.response.data &&
|
||||||
// Перенаправляем на страницу логина
|
typeof error.response.data === 'string' &&
|
||||||
window.location.href = '/login';
|
error.response.data.includes('<!DOCTYPE')) {
|
||||||
|
console.error('API Error: Server returned HTML instead of JSON');
|
||||||
|
error.message = 'Ошибка: сервер вернул HTML вместо JSON. Проверьте подключение к API.';
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ api.interceptors.response.use(
|
|||||||
// Пример функции для отправки гостевого сообщения на сервер
|
// Пример функции для отправки гостевого сообщения на сервер
|
||||||
const sendGuestMessageToServer = async (messageText) => {
|
const sendGuestMessageToServer = async (messageText) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/chat/guest-message', {
|
await axios.post('/chat/guest-message', {
|
||||||
message: messageText,
|
message: messageText,
|
||||||
// language: userLanguage.value, // TODO: Реализовать получение языка пользователя
|
// language: userLanguage.value, // TODO: Реализовать получение языка пользователя
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,4 +55,14 @@ a {
|
|||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
text-decoration: underline; /* Подчеркивание при наведении */
|
text-decoration: underline; /* Подчеркивание при наведении */
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100vw !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ const handleWalletAuth = async () => {
|
|||||||
const disconnectWallet = async () => {
|
const disconnectWallet = async () => {
|
||||||
console.log('[BaseLayout] Выполняется выход из системы...');
|
console.log('[BaseLayout] Выполняется выход из системы...');
|
||||||
try {
|
try {
|
||||||
await api.post('/api/auth/logout');
|
await api.post('/auth/logout');
|
||||||
showSuccessMessage('Вы успешно вышли из системы');
|
showSuccessMessage('Вы успешно вышли из системы');
|
||||||
removeFromStorage('guestMessages');
|
removeFromStorage('guestMessages');
|
||||||
removeFromStorage('hasUserSentMessage');
|
removeFromStorage('hasUserSentMessage');
|
||||||
|
|||||||
@@ -488,6 +488,33 @@ async function handleAiReply() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-container {
|
.chat-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -714,33 +741,61 @@ async function handleAiReply() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.chat-container {
|
.chat-input {
|
||||||
margin: var(--spacing-xs) auto;
|
position: fixed !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 12px 12px 0 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
padding: var(--spacing-sm);
|
padding-bottom: 70px !important; /* чтобы сообщения не перекрывались input */
|
||||||
|
max-height: calc(100vh - 70px) !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.input-area {
|
|
||||||
gap: 4px;
|
@media (max-width: 600px) {
|
||||||
|
.chat-input {
|
||||||
|
position: fixed !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 12px 12px 0 0 !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
background: #f8f8f8 !important;
|
||||||
|
border-top: 1px solid #eee !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
}
|
}
|
||||||
|
.chat-messages {
|
||||||
.chat-icon-btn {
|
padding-bottom: 70px !important; /* чтобы сообщения не перекрывались input */
|
||||||
width: 28px;
|
max-height: calc(100vh - 70px) !important;
|
||||||
height: 28px;
|
overflow-y: auto !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
}
|
}
|
||||||
|
.chat-container {
|
||||||
.chat-icon-btn svg {
|
padding: 0 !important;
|
||||||
width: 18px;
|
margin: 0 !important;
|
||||||
height: 18px;
|
height: 100vh !important;
|
||||||
}
|
max-height: 100vh !important;
|
||||||
|
min-height: 0 !important;
|
||||||
.preview-item {
|
box-sizing: border-box !important;
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-area textarea {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-spinner {
|
.ai-spinner {
|
||||||
animation: ai-spin 1s linear infinite;
|
animation: ai-spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,11 +182,25 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.header-text {
|
.title {
|
||||||
display: none; /* Hide text on very small screens */
|
font-size: 1em;
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.7em;
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.header-content {
|
.header-content {
|
||||||
justify-content: flex-end; /* Align button to the right */
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.header-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ async function submitImport() {
|
|||||||
return obj;
|
return obj;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/users/import', {
|
const resp = await fetch('/users/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(contacts)
|
body: JSON.stringify(contacts)
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ async function save() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (props.rule && props.rule.id) {
|
if (props.rule && props.rule.id) {
|
||||||
await axios.put(`/api/settings/ai-assistant-rules/${props.rule.id}`, { name: name.value, description: description.value, rules });
|
await axios.put(`/settings/ai-assistant-rules/${props.rule.id}`, { name: name.value, description: description.value, rules });
|
||||||
} else {
|
} else {
|
||||||
await axios.post('/api/settings/ai-assistant-rules', { name: name.value, description: description.value, rules });
|
await axios.post('/settings/ai-assistant-rules', { name: name.value, description: description.value, rules });
|
||||||
}
|
}
|
||||||
emit('close', true);
|
emit('close', true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const getRagResultClass = () => {
|
|||||||
const refreshStatus = async () => {
|
const refreshStatus = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/monitoring');
|
const response = await axios.get('/monitoring');
|
||||||
monitoringData.value = response.data;
|
monitoringData.value = response.data;
|
||||||
lastUpdate.value = new Date().toLocaleString('ru-RU');
|
lastUpdate.value = new Date().toLocaleString('ru-RU');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -133,7 +133,7 @@ const testRAG = async () => {
|
|||||||
ragTesting.value = true;
|
ragTesting.value = true;
|
||||||
ragResult.value = null;
|
ragResult.value = null;
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/rag/answer', {
|
const response = await axios.post('/rag/answer', {
|
||||||
tableId: 28,
|
tableId: 28,
|
||||||
question: ragQuestion.value,
|
question: ragQuestion.value,
|
||||||
userTags: [],
|
userTags: [],
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const emit = defineEmits(['close', 'success']);
|
|||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
const response = await axios.post('/api/auth/email/request', {
|
const response = await axios.post('/auth/email/request', {
|
||||||
email: email.value,
|
email: email.value,
|
||||||
});
|
});
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
@@ -76,7 +76,7 @@ const emit = defineEmits(['close', 'success']);
|
|||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
const response = await axios.post('/api/auth/email/verify-code', {
|
const response = await axios.post('/auth/email/verify-code', {
|
||||||
email: email.value,
|
email: email.value,
|
||||||
code: code.value,
|
code: code.value,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const updateIdentities = async () => {
|
|||||||
if (!isAuthenticated.value || !userId.value) return;
|
if (!isAuthenticated.value || !userId.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/auth/identities');
|
const response = await axios.get('/auth/identities');
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
// Фильтруем идентификаторы: убираем гостевые и оставляем только уникальные
|
// Фильтруем идентификаторы: убираем гостевые и оставляем только уникальные
|
||||||
const filteredIdentities = response.data.identities
|
const filteredIdentities = response.data.identities
|
||||||
@@ -71,7 +71,7 @@ const stopIdentitiesPolling = () => {
|
|||||||
|
|
||||||
const checkTokenBalances = async (address) => {
|
const checkTokenBalances = async (address) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/auth/check-tokens/${address}`);
|
const response = await axios.get(`/auth/check-tokens/${address}`);
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
tokenBalances.value = response.data.balances;
|
tokenBalances.value = response.data.balances;
|
||||||
return response.data.balances;
|
return response.data.balances;
|
||||||
@@ -194,7 +194,7 @@ const linkMessages = async () => {
|
|||||||
/* Удаляем ненужный вызов
|
/* Удаляем ненужный вызов
|
||||||
try {
|
try {
|
||||||
// Отправляем запрос на связывание сообщений
|
// Отправляем запрос на связывание сообщений
|
||||||
const response = await axios.post('/api/auth/link-guest-messages', identifiersData);
|
const response = await axios.post('/auth/link-guest-messages', identifiersData);
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
console.log('Messages linked successfully:', response.data);
|
console.log('Messages linked successfully:', response.data);
|
||||||
@@ -257,7 +257,7 @@ const linkMessages = async () => {
|
|||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/auth/check');
|
const response = await axios.get('/auth/check');
|
||||||
console.log('Auth check response:', response.data);
|
console.log('Auth check response:', response.data);
|
||||||
|
|
||||||
const wasAuthenticated = isAuthenticated.value;
|
const wasAuthenticated = isAuthenticated.value;
|
||||||
@@ -315,7 +315,7 @@ const checkAuth = async () => {
|
|||||||
const disconnect = async () => {
|
const disconnect = async () => {
|
||||||
try {
|
try {
|
||||||
// Удаляем все идентификаторы перед выходом
|
// Удаляем все идентификаторы перед выходом
|
||||||
await axios.post('/api/auth/logout');
|
await axios.post('/auth/logout');
|
||||||
|
|
||||||
// Обновляем состояние в памяти
|
// Обновляем состояние в памяти
|
||||||
updateAuth({
|
updateAuth({
|
||||||
@@ -423,7 +423,7 @@ const updateConnectionDisplay = (isConnected, authType, authData = {}) => {
|
|||||||
* @returns {Promise<Object>} - Результат операции
|
* @returns {Promise<Object>} - Результат операции
|
||||||
*/
|
*/
|
||||||
const linkIdentity = async (type, value) => {
|
const linkIdentity = async (type, value) => {
|
||||||
const response = await axios.post('/api/link', {
|
const response = await axios.post('/link', {
|
||||||
type,
|
type,
|
||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
@@ -437,7 +437,7 @@ const linkIdentity = async (type, value) => {
|
|||||||
* @returns {Promise<Object>} - Результат операции
|
* @returns {Promise<Object>} - Результат операции
|
||||||
*/
|
*/
|
||||||
const deleteIdentity = async (provider, providerId) => {
|
const deleteIdentity = async (provider, providerId) => {
|
||||||
const response = await axios.delete(`/api/${provider}/${encodeURIComponent(providerId)}`);
|
const response = await axios.delete(`/${provider}/${encodeURIComponent(providerId)}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function useAuthFlow(options = {}) {
|
|||||||
telegramAuth.value.isLoading = true;
|
telegramAuth.value.isLoading = true;
|
||||||
telegramAuth.value.error = '';
|
telegramAuth.value.error = '';
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/api/auth/telegram/init');
|
const response = await api.post('/auth/telegram/init');
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
telegramAuth.value.verificationCode = response.data.verificationCode;
|
telegramAuth.value.verificationCode = response.data.verificationCode;
|
||||||
telegramAuth.value.botLink = response.data.botLink;
|
telegramAuth.value.botLink = response.data.botLink;
|
||||||
@@ -130,7 +130,7 @@ export function useAuthFlow(options = {}) {
|
|||||||
emailAuth.value.isLoading = true;
|
emailAuth.value.isLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/api/auth/email/init', { email: emailAuth.value.email });
|
const response = await api.post('/auth/email/init', { email: emailAuth.value.email });
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
emailAuth.value.verificationEmail = emailAuth.value.email; // Сохраняем email
|
emailAuth.value.verificationEmail = emailAuth.value.email; // Сохраняем email
|
||||||
emailAuth.value.showForm = false;
|
emailAuth.value.showForm = false;
|
||||||
@@ -161,7 +161,7 @@ export function useAuthFlow(options = {}) {
|
|||||||
emailAuth.value.isVerifying = true;
|
emailAuth.value.isVerifying = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/api/auth/email/verify-code', {
|
const response = await api.post('/auth/email/verify-code', {
|
||||||
email: emailAuth.value.verificationEmail,
|
email: emailAuth.value.verificationEmail,
|
||||||
code: emailAuth.value.verificationCode,
|
code: emailAuth.value.verificationCode,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function useBlockchainNetworks() {
|
|||||||
const fetchNetworks = async () => {
|
const fetchNetworks = async () => {
|
||||||
loadingNetworks.value = true;
|
loadingNetworks.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get('/api/settings/rpc');
|
const { data } = await axios.get('/settings/rpc');
|
||||||
const networksArr = data.data || [];
|
const networksArr = data.data || [];
|
||||||
networks.value = networksArr.map(n => ({
|
networks.value = networksArr.map(n => ({
|
||||||
value: n.network_id,
|
value: n.network_id,
|
||||||
@@ -163,7 +163,7 @@ export default function useBlockchainNetworks() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Формируем запрос на бэкенд для проверки RPC
|
// Формируем запрос на бэкенд для проверки RPC
|
||||||
const response = await axios.post('/api/settings/rpc-test', {
|
const response = await axios.post('/settings/rpc-test', {
|
||||||
networkId,
|
networkId,
|
||||||
rpcUrl
|
rpcUrl
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function useChat(auth) {
|
|||||||
let totalMessages = -1;
|
let totalMessages = -1;
|
||||||
if (initial || messageLoading.value.offset === 0) {
|
if (initial || messageLoading.value.offset === 0) {
|
||||||
try {
|
try {
|
||||||
const countResponse = await api.get('/api/chat/history', { params: { count_only: true } });
|
const countResponse = await api.get('/chat/history', { params: { count_only: true } });
|
||||||
if (!countResponse.data.success) throw new Error('Не удалось получить количество сообщений');
|
if (!countResponse.data.success) throw new Error('Не удалось получить количество сообщений');
|
||||||
totalMessages = countResponse.data.total || countResponse.data.count || 0;
|
totalMessages = countResponse.data.total || countResponse.data.count || 0;
|
||||||
console.log(`[useChat] Всего сообщений в истории: ${totalMessages}`);
|
console.log(`[useChat] Всего сообщений в истории: ${totalMessages}`);
|
||||||
@@ -97,7 +97,7 @@ export function useChat(auth) {
|
|||||||
console.log(`[useChat] Рассчитано начальное смещение: ${effectiveOffset}`);
|
console.log(`[useChat] Рассчитано начальное смещение: ${effectiveOffset}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get('/api/chat/history', {
|
const response = await api.get('/chat/history', {
|
||||||
params: {
|
params: {
|
||||||
offset: effectiveOffset,
|
offset: effectiveOffset,
|
||||||
limit: messageLoading.value.limit,
|
limit: messageLoading.value.limit,
|
||||||
@@ -225,14 +225,14 @@ export function useChat(auth) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let apiUrl = '/api/chat/message';
|
let apiUrl = '/chat/message';
|
||||||
if (isGuestMessage) {
|
if (isGuestMessage) {
|
||||||
if (!guestId.value) {
|
if (!guestId.value) {
|
||||||
guestId.value = initGuestId();
|
guestId.value = initGuestId();
|
||||||
setToStorage('guestId', guestId.value);
|
setToStorage('guestId', guestId.value);
|
||||||
}
|
}
|
||||||
formData.append('guestId', guestId.value);
|
formData.append('guestId', guestId.value);
|
||||||
apiUrl = '/api/chat/guest-message';
|
apiUrl = '/chat/guest-message';
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.post(apiUrl, formData, {
|
const response = await api.post(apiUrl, formData, {
|
||||||
@@ -352,7 +352,7 @@ export function useChat(auth) {
|
|||||||
const linkGuestMessagesAfterAuth = async () => {
|
const linkGuestMessagesAfterAuth = async () => {
|
||||||
if (!guestId.value) return;
|
if (!guestId.value) return;
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/api/chat/process-guest', { guestId: guestId.value });
|
const response = await api.post('/chat/process-guest', { guestId: guestId.value });
|
||||||
if (response.data.success && response.data.conversationId) {
|
if (response.data.success && response.data.conversationId) {
|
||||||
// Можно сразу загрузить историю по этому диалогу, если нужно
|
// Можно сразу загрузить историю по этому диалогу, если нужно
|
||||||
await loadMessages({ initial: true });
|
await loadMessages({ initial: true });
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function useContactsAndMessagesWebSocket() {
|
|||||||
|
|
||||||
async function fetchContactsReadStatus() {
|
async function fetchContactsReadStatus() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get('/api/users/read-contacts-status');
|
const { data } = await axios.get('/users/read-contacts-status');
|
||||||
readContacts.value = data || [];
|
readContacts.value = data || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
readContacts.value = [];
|
readContacts.value = [];
|
||||||
@@ -60,7 +60,7 @@ export function useContactsAndMessagesWebSocket() {
|
|||||||
|
|
||||||
async function markContactAsRead(contactId) {
|
async function markContactAsRead(contactId) {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/users/mark-contact-read', { contactId });
|
await axios.post('/users/mark-contact-read', { contactId });
|
||||||
if (!readContacts.value.includes(contactId)) {
|
if (!readContacts.value.includes(contactId)) {
|
||||||
readContacts.value.push(contactId);
|
readContacts.value.push(contactId);
|
||||||
updateNewContacts();
|
updateNewContacts();
|
||||||
@@ -70,7 +70,7 @@ export function useContactsAndMessagesWebSocket() {
|
|||||||
|
|
||||||
async function fetchReadStatus() {
|
async function fetchReadStatus() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get('/api/messages/read-status');
|
const { data } = await axios.get('/messages/read-status');
|
||||||
lastReadMessageDate.value = data || {};
|
lastReadMessageDate.value = data || {};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastReadMessageDate.value = {};
|
lastReadMessageDate.value = {};
|
||||||
@@ -97,7 +97,7 @@ export function useContactsAndMessagesWebSocket() {
|
|||||||
const maxDate = Math.max(...userMessages.map(m => new Date(m.created_at).getTime()));
|
const maxDate = Math.max(...userMessages.map(m => new Date(m.created_at).getTime()));
|
||||||
const maxDateISO = new Date(maxDate).toISOString();
|
const maxDateISO = new Date(maxDate).toISOString();
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/messages/mark-read', { userId, lastReadAt: maxDateISO });
|
await axios.post('/messages/mark-read', { userId, lastReadAt: maxDateISO });
|
||||||
lastReadMessageDate.value[userId] = maxDateISO;
|
lastReadMessageDate.value[userId] = maxDateISO;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,8 @@ export function useContactsAndMessagesWebSocket() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupWebSocket() {
|
function setupWebSocket() {
|
||||||
ws = new WebSocket('ws://localhost:8000');
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
ws = new WebSocket(`${wsProtocol}://${window.location.host}/ws`);
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === 'contacts-updated') {
|
if (data.type === 'contacts-updated') {
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ import ElementPlus from 'element-plus';
|
|||||||
import 'element-plus/dist/index.css';
|
import 'element-plus/dist/index.css';
|
||||||
|
|
||||||
// Настройка axios
|
// Настройка axios
|
||||||
// В Docker контейнере localhost:8000 не работает, поэтому используем явное значение
|
axios.defaults.baseURL = '/api';
|
||||||
const apiUrl =
|
|
||||||
window.location.hostname === 'localhost'
|
|
||||||
? 'http://localhost:8000'
|
|
||||||
: 'http://dapp-backend:8000'; // имя контейнера backend
|
|
||||||
axios.defaults.baseURL = apiUrl;
|
|
||||||
axios.defaults.withCredentials = true;
|
axios.defaults.withCredentials = true;
|
||||||
|
|
||||||
// Создаем и монтируем приложение Vue
|
// Создаем и монтируем приложение Vue
|
||||||
@@ -33,7 +28,7 @@ app.use(ElementPlus);
|
|||||||
// ]).catch(err => console.error('Failed to load API mocks:', err));
|
// ]).catch(err => console.error('Failed to load API mocks:', err));
|
||||||
// }
|
// }
|
||||||
|
|
||||||
console.log('API URL:', apiUrl);
|
console.log('API URL:', axios.defaults.baseURL);
|
||||||
console.log('main.js: Starting application with router');
|
console.log('main.js: Starting application with router');
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ const routes = [
|
|||||||
name: 'settings-interface',
|
name: 'settings-interface',
|
||||||
component: SettingsInterfaceView,
|
component: SettingsInterfaceView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'webssh',
|
||||||
|
name: 'settings-webssh',
|
||||||
|
component: () => import('../views/settings/WebSshSettingsView.vue'),
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: 'telegram',
|
path: 'telegram',
|
||||||
@@ -170,7 +175,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
// Проверяем аутентификацию, если маршрут требует авторизации
|
// Проверяем аутентификацию, если маршрут требует авторизации
|
||||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/auth/check');
|
const response = await axios.get('/auth/check');
|
||||||
if (response.data.authenticated) {
|
if (response.data.authenticated) {
|
||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import api from '../api/axios';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
async getContacts() {
|
async getContacts() {
|
||||||
const res = await api.get('/api/users');
|
const res = await api.get('/users');
|
||||||
if (res.data && res.data.success) {
|
if (res.data && res.data.success) {
|
||||||
return res.data.contacts;
|
return res.data.contacts;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
async updateContact(id, data) {
|
async updateContact(id, data) {
|
||||||
const res = await api.patch(`/api/users/${id}`, data);
|
const res = await api.patch(`/users/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async deleteContact(id) {
|
async deleteContact(id) {
|
||||||
try {
|
try {
|
||||||
const res = await api.delete(`/api/users/${id}`);
|
const res = await api.delete(`/users/${id}`);
|
||||||
console.log('Ответ на удаление контакта:', res.status, res.data);
|
console.log('Ответ на удаление контакта:', res.status, res.data);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -23,18 +23,18 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getContactById(id) {
|
async getContactById(id) {
|
||||||
const res = await api.get(`/api/users/${id}`);
|
const res = await api.get(`/users/${id}`);
|
||||||
if (res.data && res.data.id) {
|
if (res.data && res.data.id) {
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
async blockContact(id) {
|
async blockContact(id) {
|
||||||
const res = await api.patch(`/api/users/${id}/block`);
|
const res = await api.patch(`/users/${id}/block`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async unblockContact(id) {
|
async unblockContact(id) {
|
||||||
const res = await api.patch(`/api/users/${id}/unblock`);
|
const res = await api.patch(`/users/${id}/unblock`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class DLEService {
|
|||||||
*/
|
*/
|
||||||
async getDefaultSettings() {
|
async getDefaultSettings() {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/dle/settings');
|
const response = await api.get('/dle/settings');
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при получении настроек DLE:', error);
|
console.error('Ошибка при получении настроек DLE:', error);
|
||||||
@@ -25,7 +25,7 @@ class DLEService {
|
|||||||
*/
|
*/
|
||||||
async createDLE(dleParams) {
|
async createDLE(dleParams) {
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/api/dle', dleParams);
|
const response = await api.post('/dle', dleParams);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при создании DLE:', error);
|
console.error('Ошибка при создании DLE:', error);
|
||||||
@@ -39,7 +39,7 @@ class DLEService {
|
|||||||
*/
|
*/
|
||||||
async getAllDLEs() {
|
async getAllDLEs() {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/dle');
|
const response = await api.get('/dle');
|
||||||
|
|
||||||
// Проверяем и нормализуем поля isicCodes для всех DLE
|
// Проверяем и нормализуем поля isicCodes для всех DLE
|
||||||
if (response.data.data && Array.isArray(response.data.data)) {
|
if (response.data.data && Array.isArray(response.data.data)) {
|
||||||
@@ -65,7 +65,7 @@ class DLEService {
|
|||||||
*/
|
*/
|
||||||
async deleteDLE(tokenAddress) {
|
async deleteDLE(tokenAddress) {
|
||||||
try {
|
try {
|
||||||
const response = await api.delete(`/api/dle/${tokenAddress}`);
|
const response = await api.delete(`/dle/${tokenAddress}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при удалении DLE:', error);
|
console.error('Ошибка при удалении DLE:', error);
|
||||||
@@ -80,7 +80,7 @@ class DLEService {
|
|||||||
*/
|
*/
|
||||||
async deleteEmptyDLE(fileName) {
|
async deleteEmptyDLE(fileName) {
|
||||||
try {
|
try {
|
||||||
const response = await api.delete(`/api/dle/empty/${fileName}`);
|
const response = await api.delete(`/dle/empty/${fileName}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при удалении пустого DLE:', error);
|
console.error('Ошибка при удалении пустого DLE:', error);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import axios from 'axios';
|
|||||||
export default {
|
export default {
|
||||||
async getMessagesByUserId(userId) {
|
async getMessagesByUserId(userId) {
|
||||||
if (!userId) return [];
|
if (!userId) return [];
|
||||||
const { data } = await axios.get(`/api/messages?userId=${userId}`);
|
const { data } = await axios.get(`/messages?userId=${userId}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
async sendMessage({ conversationId, message, attachments = [], toUserId }) {
|
async sendMessage({ conversationId, message, attachments = [], toUserId }) {
|
||||||
@@ -14,7 +14,7 @@ export default {
|
|||||||
attachments.forEach(file => {
|
attachments.forEach(file => {
|
||||||
formData.append('attachments', file);
|
formData.append('attachments', file);
|
||||||
});
|
});
|
||||||
const { data } = await axios.post('/api/chat/message', formData, {
|
const { data } = await axios.post('/chat/message', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
withCredentials: true
|
withCredentials: true
|
||||||
});
|
});
|
||||||
@@ -22,20 +22,20 @@ export default {
|
|||||||
},
|
},
|
||||||
async getMessagesByConversationId(conversationId) {
|
async getMessagesByConversationId(conversationId) {
|
||||||
if (!conversationId) return [];
|
if (!conversationId) return [];
|
||||||
const { data } = await axios.get(`/api/messages?conversationId=${conversationId}`);
|
const { data } = await axios.get(`/messages?conversationId=${conversationId}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
async getConversationByUserId(userId) {
|
async getConversationByUserId(userId) {
|
||||||
if (!userId) return null;
|
if (!userId) return null;
|
||||||
const { data } = await axios.get(`/api/messages/conversations?userId=${userId}`);
|
const { data } = await axios.get(`/messages/conversations?userId=${userId}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
async generateAiDraft(conversationId, messages, language = 'auto') {
|
async generateAiDraft(conversationId, messages, language = 'auto') {
|
||||||
const { data } = await axios.post('/api/chat/ai-draft', { conversationId, messages, language });
|
const { data } = await axios.post('/chat/ai-draft', { conversationId, messages, language });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
async broadcastMessage({ userId, message }) {
|
async broadcastMessage({ userId, message }) {
|
||||||
const { data } = await axios.post('/api/messages/broadcast', {
|
const { data } = await axios.post('/messages/broadcast', {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
content: message
|
content: message
|
||||||
}, {
|
}, {
|
||||||
@@ -46,6 +46,6 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getAllMessages() {
|
export async function getAllMessages() {
|
||||||
const { data } = await axios.get('/api/messages');
|
const { data } = await axios.get('/messages');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
|
|
||||||
const tablesApi = '/api/tables';
|
const tablesApi = '/tables';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async getTables() {
|
async getTables() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import api from '../api/axios';
|
|||||||
// Получение балансов токенов
|
// Получение балансов токенов
|
||||||
export const fetchTokenBalances = async (address = null) => {
|
export const fetchTokenBalances = async (address = null) => {
|
||||||
try {
|
try {
|
||||||
let url = '/api/tokens/balances';
|
let url = '/tokens/balances';
|
||||||
if (address) {
|
if (address) {
|
||||||
url += `?address=${encodeURIComponent(address)}`;
|
url += `?address=${encodeURIComponent(address)}`;
|
||||||
console.log(`Fetching token balances for specific address: ${address}`);
|
console.log(`Fetching token balances for specific address: ${address}`);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function connectWithWallet() {
|
|||||||
|
|
||||||
// Запрашиваем nonce с сервера
|
// Запрашиваем nonce с сервера
|
||||||
console.log('Requesting nonce...');
|
console.log('Requesting nonce...');
|
||||||
const nonceResponse = await axios.get(`/api/auth/nonce?address=${address}`);
|
const nonceResponse = await axios.get(`/auth/nonce?address=${address}`);
|
||||||
const nonce = nonceResponse.data.nonce;
|
const nonce = nonceResponse.data.nonce;
|
||||||
console.log('Got nonce:', nonce);
|
console.log('Got nonce:', nonce);
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export async function connectWithWallet() {
|
|||||||
|
|
||||||
// Отправляем подпись на сервер для верификации
|
// Отправляем подпись на сервер для верификации
|
||||||
console.log('Sending verification request...');
|
console.log('Sending verification request...');
|
||||||
const verificationResponse = await axios.post('/api/auth/verify', {
|
const verificationResponse = await axios.post('/auth/verify', {
|
||||||
message,
|
message,
|
||||||
signature,
|
signature,
|
||||||
address,
|
address,
|
||||||
|
|||||||
475
frontend/src/services/webSshService.js
Normal file
475
frontend/src/services/webSshService.js
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
/**
|
||||||
|
* Сервис для управления WEB SSH туннелем
|
||||||
|
* Взаимодействует с локальным агентом на порту 12345
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LOCAL_AGENT_URL = 'http://localhost:12345';
|
||||||
|
|
||||||
|
class WebSshService {
|
||||||
|
constructor() {
|
||||||
|
this.isAgentRunning = false;
|
||||||
|
this.connectionStatus = {
|
||||||
|
connected: false,
|
||||||
|
domain: null,
|
||||||
|
tunnelId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка доступности локального агента
|
||||||
|
*/
|
||||||
|
async checkAgentStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LOCAL_AGENT_URL}/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.isAgentRunning = true;
|
||||||
|
return { running: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isAgentRunning = false;
|
||||||
|
return { running: false };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Агент не доступен:', error);
|
||||||
|
this.isAgentRunning = false;
|
||||||
|
return { running: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Автоматическая установка и запуск агента
|
||||||
|
*/
|
||||||
|
async installAndStartAgent() {
|
||||||
|
try {
|
||||||
|
// Сначала проверяем, может агент уже запущен
|
||||||
|
const status = await this.checkAgentStatus();
|
||||||
|
if (status.running) {
|
||||||
|
return { success: true, message: 'Агент уже запущен' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся запустить агент через системный вызов
|
||||||
|
const response = await fetch(`${LOCAL_AGENT_URL}/install`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'install_and_start'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
this.isAgentRunning = true;
|
||||||
|
return { success: true, message: 'Агент успешно установлен и запущен' };
|
||||||
|
} else {
|
||||||
|
// Если агент не отвечает, пытаемся скачать и установить его
|
||||||
|
return await this.downloadAndInstallAgent();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при установке агента:', error);
|
||||||
|
return await this.downloadAndInstallAgent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачивание и установка агента
|
||||||
|
*/
|
||||||
|
async downloadAndInstallAgent() {
|
||||||
|
try {
|
||||||
|
// Создаем скрипт для скачивания и установки агента
|
||||||
|
const installScript = `
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Создаем директорию для агента
|
||||||
|
mkdir -p ~/.webssh-agent
|
||||||
|
cd ~/.webssh-agent
|
||||||
|
|
||||||
|
# Скачиваем агент (пока создаем локально)
|
||||||
|
cat > agent.js << 'EOF'
|
||||||
|
${this.getAgentCode()}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Скачиваем package.json
|
||||||
|
cat > package.json << 'EOF'
|
||||||
|
{
|
||||||
|
"name": "webssh-agent",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Local SSH tunnel agent",
|
||||||
|
"main": "agent.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node agent.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"ssh2": "^1.14.0",
|
||||||
|
"node-ssh": "^13.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Устанавливаем зависимости
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Запускаем агент в фоне
|
||||||
|
nohup node agent.js > agent.log 2>&1 &
|
||||||
|
|
||||||
|
echo "Агент установлен и запущен"
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Создаем Blob со скриптом
|
||||||
|
const blob = new Blob([installScript], { type: 'application/x-sh' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Создаем ссылку для скачивания
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'install-webssh-agent.sh';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Скачайте и запустите скрипт install-webssh-agent.sh для установки агента',
|
||||||
|
requiresManualInstall: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при создании установочного скрипта:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при подготовке установки агента',
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создание SSH туннеля
|
||||||
|
*/
|
||||||
|
async createTunnel(config) {
|
||||||
|
try {
|
||||||
|
// Проверяем, что агент запущен
|
||||||
|
const agentStatus = await this.checkAgentStatus();
|
||||||
|
if (!agentStatus.running) {
|
||||||
|
// Пытаемся установить и запустить агент
|
||||||
|
const installResult = await this.installAndStartAgent();
|
||||||
|
if (!installResult.success) {
|
||||||
|
return installResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем конфигурацию туннеля агенту
|
||||||
|
const response = await fetch(`${LOCAL_AGENT_URL}/tunnel/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain: config.domain,
|
||||||
|
email: config.email,
|
||||||
|
sshHost: config.sshHost,
|
||||||
|
sshUser: config.sshUser,
|
||||||
|
sshKey: config.sshKey,
|
||||||
|
localPort: config.localPort || 5173,
|
||||||
|
serverPort: config.serverPort || 9000,
|
||||||
|
sshPort: config.sshPort || 22
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.connectionStatus = {
|
||||||
|
connected: true,
|
||||||
|
domain: config.domain,
|
||||||
|
tunnelId: result.tunnelId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Ошибка при создании туннеля'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при создании туннеля:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Ошибка подключения к агенту: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отключение туннеля
|
||||||
|
*/
|
||||||
|
async disconnectTunnel() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LOCAL_AGENT_URL}/tunnel/disconnect`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tunnelId: this.connectionStatus.tunnelId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.connectionStatus = {
|
||||||
|
connected: false,
|
||||||
|
domain: null,
|
||||||
|
tunnelId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Ошибка при отключении туннеля'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при отключении туннеля:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Ошибка подключения к агенту: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение статуса подключения
|
||||||
|
*/
|
||||||
|
async getStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LOCAL_AGENT_URL}/tunnel/status`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
this.connectionStatus = result;
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
domain: null,
|
||||||
|
tunnelId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении статуса:', error);
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
domain: null,
|
||||||
|
tunnelId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение кода агента для установки
|
||||||
|
*/
|
||||||
|
getAgentCode() {
|
||||||
|
return `
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { NodeSSH } = require('node-ssh');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 12345;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Состояние туннеля
|
||||||
|
let tunnelState = {
|
||||||
|
connected: false,
|
||||||
|
domain: null,
|
||||||
|
tunnelId: null,
|
||||||
|
sshProcess: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Здоровье агента
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создание туннеля
|
||||||
|
app.post('/tunnel/create', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { domain, email, sshHost, sshUser, sshKey, localPort, serverPort, sshPort } = req.body;
|
||||||
|
|
||||||
|
console.log('Создание туннеля для домена:', domain);
|
||||||
|
|
||||||
|
// Сохраняем SSH ключ во временный файл
|
||||||
|
const keyPath = path.join(__dirname, 'temp_ssh_key');
|
||||||
|
fs.writeFileSync(keyPath, sshKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
// Подключаемся к серверу и настраиваем NGINX
|
||||||
|
const ssh = new NodeSSH();
|
||||||
|
await ssh.connect({
|
||||||
|
host: sshHost,
|
||||||
|
username: sshUser,
|
||||||
|
privateKey: sshKey,
|
||||||
|
port: sshPort
|
||||||
|
});
|
||||||
|
|
||||||
|
// Установка NGINX и certbot
|
||||||
|
await ssh.execCommand('apt-get update && apt-get install -y nginx certbot python3-certbot-nginx');
|
||||||
|
|
||||||
|
// Создание конфигурации NGINX
|
||||||
|
const nginxConfig = \`
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name \${domain};
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:\${serverPort};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`;
|
||||||
|
|
||||||
|
await ssh.execCommand(\`echo '\${nginxConfig}' > /etc/nginx/sites-available/\${domain}\`);
|
||||||
|
await ssh.execCommand(\`ln -sf /etc/nginx/sites-available/\${domain} /etc/nginx/sites-enabled/\`);
|
||||||
|
await ssh.execCommand('nginx -t && systemctl reload nginx');
|
||||||
|
|
||||||
|
// Получение SSL сертификата
|
||||||
|
await ssh.execCommand(\`certbot --nginx -d \${domain} --non-interactive --agree-tos --email \${email}\`);
|
||||||
|
|
||||||
|
ssh.dispose();
|
||||||
|
|
||||||
|
// Создание SSH туннеля
|
||||||
|
const tunnelId = Date.now().toString();
|
||||||
|
const sshArgs = [
|
||||||
|
'-i', keyPath,
|
||||||
|
'-p', sshPort.toString(),
|
||||||
|
'-R', \`\${serverPort}:localhost:\${localPort}\`,
|
||||||
|
'-N',
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
|
\`\${sshUser}@\${sshHost}\`
|
||||||
|
];
|
||||||
|
|
||||||
|
const sshProcess = spawn('ssh', sshArgs);
|
||||||
|
|
||||||
|
sshProcess.on('error', (error) => {
|
||||||
|
console.error('SSH процесс ошибка:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
sshProcess.on('close', (code) => {
|
||||||
|
console.log('SSH процесс завершен с кодом:', code);
|
||||||
|
tunnelState.connected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем состояние
|
||||||
|
tunnelState = {
|
||||||
|
connected: true,
|
||||||
|
domain,
|
||||||
|
tunnelId,
|
||||||
|
sshProcess
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Туннель успешно создан',
|
||||||
|
tunnelId,
|
||||||
|
domain
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка создания туннеля:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отключение туннеля
|
||||||
|
app.post('/tunnel/disconnect', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (tunnelState.sshProcess) {
|
||||||
|
tunnelState.sshProcess.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelState = {
|
||||||
|
connected: false,
|
||||||
|
domain: null,
|
||||||
|
tunnelId: null,
|
||||||
|
sshProcess: null
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Туннель отключен'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отключения туннеля:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Статус туннеля
|
||||||
|
app.get('/tunnel/status', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
connected: tunnelState.connected,
|
||||||
|
domain: tunnelState.domain,
|
||||||
|
tunnelId: tunnelState.tunnelId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Запуск сервера
|
||||||
|
app.listen(PORT, 'localhost', () => {
|
||||||
|
console.log(\`WebSSH Agent запущен на порту \${PORT}\`);
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем композабл для использования в компонентах
|
||||||
|
export function useWebSshService() {
|
||||||
|
const service = new WebSshService();
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkAgentStatus: () => service.checkAgentStatus(),
|
||||||
|
installAndStartAgent: () => service.installAndStartAgent(),
|
||||||
|
createTunnel: (config) => service.createTunnel(config),
|
||||||
|
disconnectTunnel: () => service.disconnectTunnel(),
|
||||||
|
getStatus: () => service.getStatus()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebSshService;
|
||||||
86
frontend/src/utils/api.js
Normal file
86
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Безопасный fetch с проверкой JSON-ответа
|
||||||
|
* @param {string} url - URL для запроса
|
||||||
|
* @param {object} options - Опции для fetch
|
||||||
|
* @returns {Promise<any>} - Распарсенный JSON или ошибка
|
||||||
|
*/
|
||||||
|
export async function safeFetch(url, options = {}) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
// Проверяем статус ответа
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем Content-Type
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
// Если это HTML, значит, запрос ушёл не на API
|
||||||
|
if (contentType && contentType.includes('text/html')) {
|
||||||
|
throw new Error('Сервер вернул HTML вместо JSON. Проверьте путь к API.');
|
||||||
|
}
|
||||||
|
throw new Error(`Неожиданный Content-Type: ${contentType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим JSON
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка для GET-запросов
|
||||||
|
* @param {string} url - URL для запроса
|
||||||
|
* @returns {Promise<any>} - Распарсенный JSON
|
||||||
|
*/
|
||||||
|
export async function apiGet(url) {
|
||||||
|
return safeFetch(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка для POST-запросов
|
||||||
|
* @param {string} url - URL для запроса
|
||||||
|
* @param {object} data - Данные для отправки
|
||||||
|
* @returns {Promise<any>} - Распарсенный JSON
|
||||||
|
*/
|
||||||
|
export async function apiPost(url, data) {
|
||||||
|
return safeFetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка для PUT-запросов
|
||||||
|
* @param {string} url - URL для запроса
|
||||||
|
* @param {object} data - Данные для отправки
|
||||||
|
* @returns {Promise<any>} - Распарсенный JSON
|
||||||
|
*/
|
||||||
|
export async function apiPut(url, data) {
|
||||||
|
return safeFetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка для DELETE-запросов
|
||||||
|
* @param {string} url - URL для запроса
|
||||||
|
* @returns {Promise<any>} - Распарсенный JSON
|
||||||
|
*/
|
||||||
|
export async function apiDelete(url) {
|
||||||
|
return safeFetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ export const connectWallet = async () => {
|
|||||||
|
|
||||||
// Запрашиваем nonce с сервера
|
// Запрашиваем nonce с сервера
|
||||||
console.log('Requesting nonce...');
|
console.log('Requesting nonce...');
|
||||||
const nonceResponse = await axios.get(`/api/auth/nonce?address=${normalizedAddress}`);
|
const nonceResponse = await axios.get(`/auth/nonce?address=${normalizedAddress}`);
|
||||||
const nonce = nonceResponse.data.nonce;
|
const nonce = nonceResponse.data.nonce;
|
||||||
console.log('Got nonce:', nonce);
|
console.log('Got nonce:', nonce);
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ export const connectWallet = async () => {
|
|||||||
|
|
||||||
// Отправляем верификацию на сервер
|
// Отправляем верификацию на сервер
|
||||||
console.log('Sending verification request...');
|
console.log('Sending verification request...');
|
||||||
const verifyResponse = await axios.post('/api/auth/verify', {
|
const verifyResponse = await axios.post('/auth/verify', {
|
||||||
address: normalizedAddress,
|
address: normalizedAddress,
|
||||||
signature,
|
signature,
|
||||||
nonce,
|
nonce,
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ let ws = null;
|
|||||||
|
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
if (ws) ws.close();
|
if (ws) ws.close();
|
||||||
ws = new WebSocket('ws://localhost:8000');
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
ws = new WebSocket(`${wsProtocol}://${window.location.host}/ws`);
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('[CRM] WebSocket соединение установлено');
|
console.log('[CRM] WebSocket соединение установлено');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ async function reloadDleList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
dleList.value = await dleService.getAllDLEs() || [];
|
await reloadDleList();
|
||||||
onMounted(reloadDleList);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@
|
|||||||
@send-message="handleSendMessage"
|
@send-message="handleSendMessage"
|
||||||
@load-more="loadMessages"
|
@load-more="loadMessages"
|
||||||
/>
|
/>
|
||||||
<!-- Можно добавить заглушку или пояснение -->
|
|
||||||
<div class="empty-table-placeholder">Вы видите только свои сообщения. Данные других пользователей недоступны.</div>
|
|
||||||
</template>
|
</template>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -87,33 +87,33 @@ const filteredEmbeddingModels = computed(() => {
|
|||||||
return embeddingModels.value.filter(m => m.provider === selectedLLM.value.provider);
|
return embeddingModels.value.filter(m => m.provider === selectedLLM.value.provider);
|
||||||
});
|
});
|
||||||
async function loadUserTables() {
|
async function loadUserTables() {
|
||||||
const { data } = await axios.get('/api/tables');
|
const { data } = await axios.get('/tables');
|
||||||
userTables.value = Array.isArray(data) ? data : [];
|
userTables.value = Array.isArray(data) ? data : [];
|
||||||
}
|
}
|
||||||
async function loadRules() {
|
async function loadRules() {
|
||||||
const { data } = await axios.get('/api/settings/ai-assistant-rules');
|
const { data } = await axios.get('/settings/ai-assistant-rules');
|
||||||
rulesList.value = data.rules || [];
|
rulesList.value = data.rules || [];
|
||||||
}
|
}
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
const { data } = await axios.get('/api/settings/ai-assistant');
|
const { data } = await axios.get('/settings/ai-assistant');
|
||||||
if (data.success && data.settings) {
|
if (data.success && data.settings) {
|
||||||
settings.value = data.settings;
|
settings.value = data.settings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function loadTelegramBots() {
|
async function loadTelegramBots() {
|
||||||
const { data } = await axios.get('/api/settings/telegram-settings/list');
|
const { data } = await axios.get('/settings/telegram-settings/list');
|
||||||
telegramBots.value = data.items || [];
|
telegramBots.value = data.items || [];
|
||||||
}
|
}
|
||||||
async function loadEmailList() {
|
async function loadEmailList() {
|
||||||
const { data } = await axios.get('/api/settings/email-settings/list');
|
const { data } = await axios.get('/settings/email-settings/list');
|
||||||
emailList.value = data.items || [];
|
emailList.value = data.items || [];
|
||||||
}
|
}
|
||||||
async function loadLLMModels() {
|
async function loadLLMModels() {
|
||||||
const { data } = await axios.get('/api/settings/llm-models');
|
const { data } = await axios.get('/settings/llm-models');
|
||||||
llmModels.value = data.models || [];
|
llmModels.value = data.models || [];
|
||||||
}
|
}
|
||||||
async function loadEmbeddingModels() {
|
async function loadEmbeddingModels() {
|
||||||
const { data } = await axios.get('/api/settings/embedding-models');
|
const { data } = await axios.get('/settings/embedding-models');
|
||||||
embeddingModels.value = data.models || [];
|
embeddingModels.value = data.models || [];
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -126,7 +126,7 @@ onMounted(() => {
|
|||||||
loadEmbeddingModels();
|
loadEmbeddingModels();
|
||||||
});
|
});
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
await axios.put('/api/settings/ai-assistant', settings.value);
|
await axios.put('/settings/ai-assistant', settings.value);
|
||||||
goBack();
|
goBack();
|
||||||
}
|
}
|
||||||
function openRuleEditor(ruleId = null) {
|
function openRuleEditor(ruleId = null) {
|
||||||
@@ -139,7 +139,7 @@ function openRuleEditor(ruleId = null) {
|
|||||||
}
|
}
|
||||||
async function deleteRule(ruleId) {
|
async function deleteRule(ruleId) {
|
||||||
if (!confirm('Удалить этот набор правил?')) return;
|
if (!confirm('Удалить этот набор правил?')) return;
|
||||||
await axios.delete(`/api/settings/ai-assistant-rules/${ruleId}`);
|
await axios.delete(`/settings/ai-assistant-rules/${ruleId}`);
|
||||||
await loadRules();
|
await loadRules();
|
||||||
if (settings.value.rules_id === ruleId) settings.value.rules_id = null;
|
if (settings.value.rules_id === ruleId) settings.value.rules_id = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const editMode = ref(false);
|
|||||||
|
|
||||||
const loadDbSettings = async () => {
|
const loadDbSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/api/settings/db-settings');
|
const res = await api.get('/settings/db-settings');
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
const s = res.data.settings;
|
const s = res.data.settings;
|
||||||
form.dbHost = s.db_host;
|
form.dbHost = s.db_host;
|
||||||
@@ -85,7 +85,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const saveDbSettings = async () => {
|
const saveDbSettings = async () => {
|
||||||
try {
|
try {
|
||||||
await api.put('/api/settings/db-settings', {
|
await api.put('/settings/db-settings', {
|
||||||
db_host: form.dbHost,
|
db_host: form.dbHost,
|
||||||
db_port: form.dbPort,
|
db_port: form.dbPort,
|
||||||
db_name: form.dbName,
|
db_name: form.dbName,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const editMode = ref(false);
|
|||||||
|
|
||||||
const loadEmailSettings = async () => {
|
const loadEmailSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/api/settings/email-settings');
|
const res = await api.get('/settings/email-settings');
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
const s = res.data.settings;
|
const s = res.data.settings;
|
||||||
form.smtpHost = s.smtp_host;
|
form.smtpHost = s.smtp_host;
|
||||||
@@ -98,7 +98,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const saveEmailSettings = async () => {
|
const saveEmailSettings = async () => {
|
||||||
try {
|
try {
|
||||||
await api.put('/api/settings/email-settings', {
|
await api.put('/settings/email-settings', {
|
||||||
smtp_host: form.smtpHost,
|
smtp_host: form.smtpHost,
|
||||||
smtp_port: form.smtpPort,
|
smtp_port: form.smtpPort,
|
||||||
smtp_user: form.smtpUser,
|
smtp_user: form.smtpUser,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const editMode = ref(false);
|
|||||||
|
|
||||||
const loadTelegramSettings = async () => {
|
const loadTelegramSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/api/settings/telegram-settings');
|
const res = await api.get('/settings/telegram-settings');
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
const s = res.data.settings;
|
const s = res.data.settings;
|
||||||
form.botToken = s.bot_token || '';
|
form.botToken = s.bot_token || '';
|
||||||
@@ -64,7 +64,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const saveTelegramSettings = async () => {
|
const saveTelegramSettings = async () => {
|
||||||
try {
|
try {
|
||||||
await api.put('/api/telegram-settings', {
|
await api.put('/telegram-settings', {
|
||||||
bot_token: form.botToken,
|
bot_token: form.botToken,
|
||||||
bot_username: form.botUsername
|
bot_username: form.botUsername
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const saveError = ref('');
|
|||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(`/api/settings/ai-settings/${props.provider}`);
|
const { data } = await axios.get(`/settings/ai-settings/${props.provider}`);
|
||||||
if (data.settings) {
|
if (data.settings) {
|
||||||
apiKey.value = data.settings.api_key || '';
|
apiKey.value = data.settings.api_key || '';
|
||||||
baseUrl.value = data.settings.base_url || '';
|
baseUrl.value = data.settings.base_url || '';
|
||||||
@@ -91,7 +91,7 @@ async function loadSettings() {
|
|||||||
|
|
||||||
async function loadModels() {
|
async function loadModels() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(`/api/settings/ai-settings/${props.provider}/models`);
|
const { data } = await axios.get(`/settings/ai-settings/${props.provider}/models`);
|
||||||
models.value = data.models || [];
|
models.value = data.models || [];
|
||||||
if (!selectedModel.value && models.value.length) {
|
if (!selectedModel.value && models.value.length) {
|
||||||
const first = models.value[0];
|
const first = models.value[0];
|
||||||
@@ -104,7 +104,7 @@ async function loadModels() {
|
|||||||
|
|
||||||
async function loadEmbeddingModels() {
|
async function loadEmbeddingModels() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(`/api/settings/ai-settings/${props.provider}/models`);
|
const { data } = await axios.get(`/settings/ai-settings/${props.provider}/models`);
|
||||||
embeddingModels.value = (data.models || []).filter(m => {
|
embeddingModels.value = (data.models || []).filter(m => {
|
||||||
const name = m.id || m.name || m;
|
const name = m.id || m.name || m;
|
||||||
return name && name.toLowerCase().includes('embed');
|
return name && name.toLowerCase().includes('embed');
|
||||||
@@ -123,7 +123,7 @@ async function onVerify() {
|
|||||||
verifyStatus.value = null;
|
verifyStatus.value = null;
|
||||||
verifyError.value = '';
|
verifyError.value = '';
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post(`/api/settings/ai-settings/${props.provider}/verify`, {
|
const { data } = await axios.post(`/settings/ai-settings/${props.provider}/verify`, {
|
||||||
api_key: apiKey.value,
|
api_key: apiKey.value,
|
||||||
base_url: baseUrl.value,
|
base_url: baseUrl.value,
|
||||||
});
|
});
|
||||||
@@ -144,7 +144,7 @@ async function onSave() {
|
|||||||
saveStatus.value = null;
|
saveStatus.value = null;
|
||||||
saveError.value = '';
|
saveError.value = '';
|
||||||
try {
|
try {
|
||||||
await axios.put(`/api/settings/ai-settings/${props.provider}`, {
|
await axios.put(`/settings/ai-settings/${props.provider}`, {
|
||||||
api_key: apiKey.value,
|
api_key: apiKey.value,
|
||||||
base_url: baseUrl.value,
|
base_url: baseUrl.value,
|
||||||
selected_model: selectedModel.value,
|
selected_model: selectedModel.value,
|
||||||
@@ -161,7 +161,7 @@ async function onSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
await axios.delete(`/api/settings/ai-settings/${props.provider}`);
|
await axios.delete(`/settings/ai-settings/${props.provider}`);
|
||||||
apiKey.value = '';
|
apiKey.value = '';
|
||||||
baseUrl.value = '';
|
baseUrl.value = '';
|
||||||
selectedModel.value = '';
|
selectedModel.value = '';
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ async function addToken() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.post('/api/settings/auth-token', {
|
await api.post('/settings/auth-token', {
|
||||||
...newToken,
|
...newToken,
|
||||||
minBalance: Number(newToken.minBalance) || 0
|
minBalance: Number(newToken.minBalance) || 0
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ const fetchIsicCodes = async (params = {}, optionsRef, loadingRef) => {
|
|||||||
console.debug(`[BlockchainSettingsView] Fetching ISIC codes with params: ${queryParams}`);
|
console.debug(`[BlockchainSettingsView] Fetching ISIC codes with params: ${queryParams}`);
|
||||||
|
|
||||||
// Убедитесь, что базовый URL настроен правильно (например, через axios interceptors или .env)
|
// Убедитесь, что базовый URL настроен правильно (например, через axios interceptors или .env)
|
||||||
const response = await axios.get(`/api/isic/codes?${queryParams}`);
|
const response = await axios.get(`/isic/codes?${queryParams}`);
|
||||||
|
|
||||||
if (response.data && Array.isArray(response.data.codes)) {
|
if (response.data && Array.isArray(response.data.codes)) {
|
||||||
optionsRef.value = response.data.codes.map(code => ({
|
optionsRef.value = response.data.codes.map(code => ({
|
||||||
@@ -716,7 +716,7 @@ const fetchAddressByZipcode = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[FetchByZipcode] Querying backend proxy for Nominatim with: ${params.toString()}`);
|
console.log(`[FetchByZipcode] Querying backend proxy for Nominatim with: ${params.toString()}`);
|
||||||
const response = await axios.get(`/api/geocoding/nominatim-search?${params.toString()}`);
|
const response = await axios.get(`/geocoding/nominatim-search?${params.toString()}`);
|
||||||
|
|
||||||
if (response.data && response.data.length > 0) {
|
if (response.data && response.data.length > 0) {
|
||||||
const bestMatch = response.data[0];
|
const bestMatch = response.data[0];
|
||||||
@@ -818,7 +818,7 @@ const verifyAddress = async () => {
|
|||||||
|
|
||||||
console.log(`[VerifyAddress] Querying backend proxy for Nominatim with: ${params.toString()}`);
|
console.log(`[VerifyAddress] Querying backend proxy for Nominatim with: ${params.toString()}`);
|
||||||
// Запрос теперь идет на ваш бэкенд-прокси
|
// Запрос теперь идет на ваш бэкенд-прокси
|
||||||
const response = await axios.get(`/api/geocoding/nominatim-search?${params.toString()}`);
|
const response = await axios.get(`/geocoding/nominatim-search?${params.toString()}`);
|
||||||
|
|
||||||
// Ответ от бэкенд-прокси должен иметь ту же структуру, что и прямой ответ от Nominatim
|
// Ответ от бэкенд-прокси должен иметь ту же структуру, что и прямой ответ от Nominatim
|
||||||
if (response.data && Array.isArray(response.data)) { // Проверяем, что это массив (как отвечает Nominatim)
|
if (response.data && Array.isArray(response.data)) { // Проверяем, что это массив (как отвечает Nominatim)
|
||||||
@@ -901,7 +901,7 @@ const toggleShowDeployerKey = () => {
|
|||||||
// Функция загрузки настроек RPC с сервера
|
// Функция загрузки настроек RPC с сервера
|
||||||
const loadRpcSettings = async () => {
|
const loadRpcSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/settings/rpc');
|
const response = await axios.get('/settings/rpc');
|
||||||
console.log('Ответ сервера на /api/settings/rpc:', response.data);
|
console.log('Ответ сервера на /api/settings/rpc:', response.data);
|
||||||
if (response.data && response.data.success) {
|
if (response.data && response.data.success) {
|
||||||
securitySettings.rpcConfigs = (response.data.data || []).map(rpc => ({
|
securitySettings.rpcConfigs = (response.data.data || []).map(rpc => ({
|
||||||
@@ -921,7 +921,7 @@ const loadRpcSettings = async () => {
|
|||||||
const saveRpcSettings = async () => {
|
const saveRpcSettings = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Отправляемые RPC:', securitySettings.rpcConfigs);
|
console.log('Отправляемые RPC:', securitySettings.rpcConfigs);
|
||||||
const response = await axios.post('/api/settings/rpc', {
|
const response = await axios.post('/settings/rpc', {
|
||||||
rpcConfigs: JSON.parse(JSON.stringify(securitySettings.rpcConfigs))
|
rpcConfigs: JSON.parse(JSON.stringify(securitySettings.rpcConfigs))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ async function addRpc() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.post('/api/settings/rpc', { networkId, rpcUrl, chainId });
|
await api.post('/settings/rpc', { networkId, rpcUrl, chainId });
|
||||||
emit('update'); // сигнал родителю перезагрузить список
|
emit('update'); // сигнал родителю перезагрузить список
|
||||||
resetNetworkEntry();
|
resetNetworkEntry();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const loadSettings = async () => {
|
|||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
// Загрузка RPC конфигураций
|
// Загрузка RPC конфигураций
|
||||||
const rpcResponse = await api.get('/api/settings/rpc');
|
const rpcResponse = await api.get('/settings/rpc');
|
||||||
if (rpcResponse.data && rpcResponse.data.success) {
|
if (rpcResponse.data && rpcResponse.data.success) {
|
||||||
securitySettings.rpcConfigs = (rpcResponse.data.data || []).map(rpc => ({
|
securitySettings.rpcConfigs = (rpcResponse.data.data || []).map(rpc => ({
|
||||||
networkId: rpc.network_id,
|
networkId: rpc.network_id,
|
||||||
@@ -149,7 +149,7 @@ const loadSettings = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка токенов для аутентификации
|
// Загрузка токенов для аутентификации
|
||||||
const authResponse = await api.get('/api/settings/auth-tokens');
|
const authResponse = await api.get('/settings/auth-tokens');
|
||||||
if (authResponse.data && authResponse.data.success) {
|
if (authResponse.data && authResponse.data.success) {
|
||||||
securitySettings.authTokens = (authResponse.data.data || []).map(token => ({
|
securitySettings.authTokens = (authResponse.data.data || []).map(token => ({
|
||||||
name: token.name,
|
name: token.name,
|
||||||
@@ -195,12 +195,12 @@ const saveSecuritySettings = async () => {
|
|||||||
console.log('[SecuritySettingsView] Отправка настроек на сервер:', settingsData);
|
console.log('[SecuritySettingsView] Отправка настроек на сервер:', settingsData);
|
||||||
|
|
||||||
// Отправка RPC конфигураций
|
// Отправка RPC конфигураций
|
||||||
const rpcResponse = await api.post('/api/settings/rpc', {
|
const rpcResponse = await api.post('/settings/rpc', {
|
||||||
rpcConfigs: settingsData.rpcConfigs
|
rpcConfigs: settingsData.rpcConfigs
|
||||||
});
|
});
|
||||||
|
|
||||||
// Отправка токенов для аутентификации
|
// Отправка токенов для аутентификации
|
||||||
const authResponse = await api.post('/api/settings/auth-tokens', {
|
const authResponse = await api.post('/settings/auth-tokens', {
|
||||||
authTokens: settingsData.authTokens
|
authTokens: settingsData.authTokens
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
<p>Настройки внешнего вида, локализации и пользовательского опыта.</p>
|
<p>Настройки внешнего вида, локализации и пользовательского опыта.</p>
|
||||||
<button class="details-btn" @click="$router.push('/settings/interface')">Подробнее</button>
|
<button class="details-btn" @click="$router.push('/settings/interface')">Подробнее</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="main-block">
|
||||||
|
<h3>WEB SSH</h3>
|
||||||
|
<p>Автоматическая публикация приложения в интернете через SSH-туннель.</p>
|
||||||
|
<button class="details-btn" @click="$router.push('/settings/webssh')">Подробнее</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
703
frontend/src/views/settings/WebSshSettingsView.vue
Normal file
703
frontend/src/views/settings/WebSshSettingsView.vue
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
<template>
|
||||||
|
<div class="web-ssh-settings">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>WEB SSH Туннель</h2>
|
||||||
|
<p>Автоматическая публикация локального приложения в интернете через SSH-туннель и NGINX</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!agentAvailable" class="agent-instruction-block">
|
||||||
|
<h3>Установка локального агента</h3>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<b>Windows:</b><br>
|
||||||
|
Откройте <b>PowerShell</b> или <b>Командную строку</b> и выполните:
|
||||||
|
<div class="copy-block" @click="copyToClipboard('wsl')">
|
||||||
|
<pre><code>wsl</code></pre>
|
||||||
|
<span class="copy-icon">
|
||||||
|
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="5" y="7" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/><rect x="7" y="4" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/></svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 11.5L9 15L15 7" stroke="#27ae60" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
Затем в открывшемся терминале WSL выполните:
|
||||||
|
<div class="copy-block" @click="copyToClipboard('cd ~/DApp-for-Business\nsudo bash webssh-agent/install.sh')">
|
||||||
|
<pre><code>cd ~/DApp-for-Business
|
||||||
|
sudo bash webssh-agent/install.sh</code></pre>
|
||||||
|
<span class="copy-icon">
|
||||||
|
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="5" y="7" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/><rect x="7" y="4" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/></svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 11.5L9 15L15 7" stroke="#27ae60" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Linux:</b><br>
|
||||||
|
Откройте терминал и выполните:
|
||||||
|
<div class="copy-block" @click="copyToClipboard('cd ~/DApp-for-Business\nsudo bash webssh-agent/install.sh')">
|
||||||
|
<pre><code>cd ~/DApp-for-Business
|
||||||
|
sudo bash webssh-agent/install.sh</code></pre>
|
||||||
|
<span class="copy-icon">
|
||||||
|
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="5" y="7" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/><rect x="7" y="4" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/></svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 11.5L9 15L15 7" stroke="#27ae60" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<button @click="checkAgent" class="check-btn">Проверить</button>
|
||||||
|
<div v-if="copied" class="copied-indicator">Скопировано!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма публикации всегда доступна -->
|
||||||
|
<div>
|
||||||
|
<!-- Статус подключения -->
|
||||||
|
<div class="connection-status">
|
||||||
|
<div class="status-indicator" :class="{ 'active': isConnected, 'inactive': !isConnected }"></div>
|
||||||
|
<span class="status-text">{{ connectionStatus }}</span>
|
||||||
|
<button v-if="isConnected" @click="disconnectTunnel" class="disconnect-btn">Отключить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма настроек -->
|
||||||
|
<form @submit.prevent="handleSubmit" class="tunnel-form">
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>Настройки домена</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="domain">Домен *</label>
|
||||||
|
<input
|
||||||
|
id="domain"
|
||||||
|
v-model="form.domain"
|
||||||
|
type="text"
|
||||||
|
placeholder="example.com"
|
||||||
|
required
|
||||||
|
:disabled="isConnected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email для SSL *</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
required
|
||||||
|
:disabled="isConnected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>Настройки SSH сервера</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sshHost">SSH Host/IP *</label>
|
||||||
|
<input
|
||||||
|
id="sshHost"
|
||||||
|
v-model="form.sshHost"
|
||||||
|
type="text"
|
||||||
|
placeholder="192.168.1.100 или server.example.com"
|
||||||
|
required
|
||||||
|
:disabled="isConnected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sshUser">SSH Пользователь *</label>
|
||||||
|
<input
|
||||||
|
id="sshUser"
|
||||||
|
v-model="form.sshUser"
|
||||||
|
type="text"
|
||||||
|
placeholder="root"
|
||||||
|
required
|
||||||
|
:disabled="isConnected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sshKey">SSH Приватный ключ *</label>
|
||||||
|
<textarea
|
||||||
|
id="sshKey"
|
||||||
|
v-model="form.sshKey"
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
...
|
||||||
|
-----END OPENSSH PRIVATE KEY-----"
|
||||||
|
rows="6"
|
||||||
|
required
|
||||||
|
:disabled="isConnected"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section advanced-section">
|
||||||
|
<h3>Дополнительные настройки</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="localPort">Локальный порт</label>
|
||||||
|
<input
|
||||||
|
id="localPort"
|
||||||
|
v-model="form.localPort"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
:disabled="isConnected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="serverPort">Порт сервера</label>
|
||||||
|
<input
|
||||||
|
id="serverPort"
|
||||||
|
v-model="form.serverPort"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
:disabled="isConnected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sshPort">SSH порт</label>
|
||||||
|
<input
|
||||||
|
id="sshPort"
|
||||||
|
v-model="form.sshPort"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
:disabled="isConnected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isLoading || isConnected"
|
||||||
|
class="publish-btn"
|
||||||
|
>
|
||||||
|
{{ isLoading ? 'Настройка...' : 'Опубликовать' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="resetForm"
|
||||||
|
:disabled="isLoading || isConnected"
|
||||||
|
class="reset-btn"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Лог операций -->
|
||||||
|
<div class="operation-log" v-if="logs.length > 0">
|
||||||
|
<h3>Лог операций</h3>
|
||||||
|
<div class="log-container">
|
||||||
|
<div
|
||||||
|
v-for="(log, index) in logs"
|
||||||
|
:key="index"
|
||||||
|
class="log-entry"
|
||||||
|
:class="log.type"
|
||||||
|
>
|
||||||
|
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
|
||||||
|
<span class="log-message">{{ log.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useWebSshService } from '../../services/webSshService';
|
||||||
|
|
||||||
|
const webSshService = useWebSshService();
|
||||||
|
const agentAvailable = ref(false);
|
||||||
|
|
||||||
|
// Реактивные данные
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isConnected = ref(false);
|
||||||
|
const connectionStatus = ref('Не подключено');
|
||||||
|
const logs = ref([]);
|
||||||
|
|
||||||
|
// Форма
|
||||||
|
const form = reactive({
|
||||||
|
domain: '',
|
||||||
|
email: '',
|
||||||
|
sshHost: '',
|
||||||
|
sshUser: '',
|
||||||
|
sshKey: '',
|
||||||
|
localPort: 5173,
|
||||||
|
serverPort: 9000,
|
||||||
|
sshPort: 22
|
||||||
|
});
|
||||||
|
|
||||||
|
const copied = ref(false);
|
||||||
|
let copyTimeout = null;
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text.replace(/\\n/g, '\n')).then(() => {
|
||||||
|
copied.value = true;
|
||||||
|
clearTimeout(copyTimeout);
|
||||||
|
copyTimeout = setTimeout(() => copied.value = false, 1200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePrivateKey(key) {
|
||||||
|
if (!key) return false;
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed.startsWith('-----BEGIN OPENSSH PRIVATE KEY-----')) {
|
||||||
|
console.error('Ключ не начинается с -----BEGIN OPENSSH PRIVATE KEY-----');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!trimmed.endsWith('-----END OPENSSH PRIVATE KEY-----')) {
|
||||||
|
console.error('Ключ не заканчивается на -----END OPENSSH PRIVATE KEY-----');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (trimmed.split('\n').length < 3) {
|
||||||
|
console.error('Ключ слишком короткий или не содержит переносов строк');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Методы
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
// Дополнительная валидация приватного ключа
|
||||||
|
if (!validatePrivateKey(form.sshKey)) {
|
||||||
|
addLog('error', 'Проверьте формат приватного ключа!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логирование ключа (только для отладки!)
|
||||||
|
console.log('SSH ключ (начало):', form.sshKey.slice(0, 40));
|
||||||
|
console.log('SSH ключ (конец):', form.sshKey.slice(-40));
|
||||||
|
console.log('Длина ключа:', form.sshKey.length);
|
||||||
|
|
||||||
|
// Логирование отправляемых данных (без самого ключа)
|
||||||
|
console.log('Данные для агента:', {
|
||||||
|
...form,
|
||||||
|
sshKey: form.sshKey ? `[скрыто, длина: ${form.sshKey.length}]` : 'нет ключа'
|
||||||
|
});
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
addLog('info', 'Запуск публикации...');
|
||||||
|
try {
|
||||||
|
// Публикация через агента
|
||||||
|
const result = await webSshService.createTunnel(form);
|
||||||
|
if (result.success) {
|
||||||
|
isConnected.value = true;
|
||||||
|
connectionStatus.value = `Подключено к ${form.domain}`;
|
||||||
|
addLog('success', 'SSH туннель успешно создан и настроен');
|
||||||
|
addLog('info', `Ваше приложение доступно по адресу: https://${form.domain}`);
|
||||||
|
} else {
|
||||||
|
addLog('error', result.message || 'Ошибка при создании туннеля');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
addLog('error', `Ошибка: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectTunnel = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
addLog('info', 'Отключаю SSH туннель...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await webSshService.disconnectTunnel();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
isConnected.value = false;
|
||||||
|
connectionStatus.value = 'Не подключено';
|
||||||
|
addLog('success', 'SSH туннель отключен');
|
||||||
|
} else {
|
||||||
|
addLog('error', result.message || 'Ошибка при отключении туннеля');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
addLog('error', `Ошибка: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
if (!form.domain || !form.email || !form.sshHost || !form.sshUser || !form.sshKey) {
|
||||||
|
addLog('error', 'Заполните все обязательные поля');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.email.includes('@')) {
|
||||||
|
addLog('error', 'Введите корректный email');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.sshKey.includes('-----BEGIN') || !form.sshKey.includes('-----END')) {
|
||||||
|
addLog('error', 'SSH ключ должен быть в формате OpenSSH');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, {
|
||||||
|
domain: '',
|
||||||
|
email: '',
|
||||||
|
sshHost: '',
|
||||||
|
sshUser: '',
|
||||||
|
sshKey: '',
|
||||||
|
localPort: 5173,
|
||||||
|
serverPort: 9000,
|
||||||
|
sshPort: 22
|
||||||
|
});
|
||||||
|
logs.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLog = (type, message) => {
|
||||||
|
logs.value.push({
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
return timestamp.toLocaleTimeString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkConnectionStatus = async () => {
|
||||||
|
try {
|
||||||
|
const status = await webSshService.getStatus();
|
||||||
|
isConnected.value = status.connected;
|
||||||
|
connectionStatus.value = status.connected
|
||||||
|
? `Подключено к ${status.domain}`
|
||||||
|
: 'Не подключено';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки статуса:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAgent = async () => {
|
||||||
|
const status = await webSshService.checkAgentStatus();
|
||||||
|
agentAvailable.value = status.running;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Жизненный цикл
|
||||||
|
onMounted(() => {
|
||||||
|
checkAgent();
|
||||||
|
checkConnectionStatus();
|
||||||
|
|
||||||
|
// Проверяем статус каждые 30 секунд
|
||||||
|
const interval = setInterval(checkConnectionStatus, 30000);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.web-ssh-settings {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.active {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.inactive {
|
||||||
|
background-color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnect-btn {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnect-btn:hover {
|
||||||
|
background: var(--color-danger-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-form {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h3 {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled,
|
||||||
|
.form-group textarea:disabled {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-section {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-btn {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-btn:disabled {
|
||||||
|
background: var(--color-text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn {
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log h3 {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-message {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.info .log-message {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.success .log-message {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.error .log-message {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивный дизайн */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-instruction-block {
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px solid #ffe58f;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 600px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-btn {
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
font-size: 1em;
|
||||||
|
user-select: all;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.copy-icon {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: #888;
|
||||||
|
transition: color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.copy-block:hover .copy-icon svg {
|
||||||
|
stroke: var(--color-primary);
|
||||||
|
}
|
||||||
|
.copied-indicator {
|
||||||
|
color: var(--color-success, #27ae60);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,7 +21,7 @@ const router = useRouter();
|
|||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
await axios.delete(`/api/tables/${$route.params.id}`);
|
await axios.delete(`/tables/${$route.params.id}`);
|
||||||
router.push({ name: 'tables-list' });
|
router.push({ name: 'tables-list' });
|
||||||
}
|
}
|
||||||
function cancel() {
|
function cancel() {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const description = ref('');
|
|||||||
const isRagSourceId = ref(2);
|
const isRagSourceId = ref(2);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const { data } = await axios.get(`/api/tables/${$route.params.id}`);
|
const { data } = await axios.get(`/tables/${$route.params.id}`);
|
||||||
name.value = data.name;
|
name.value = data.name;
|
||||||
description.value = data.description;
|
description.value = data.description;
|
||||||
isRagSourceId.value = data.is_rag_source_id || 2;
|
isRagSourceId.value = data.is_rag_source_id || 2;
|
||||||
|
|||||||
1
id_rsa.pub
Normal file
1
id_rsa.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEm8Ak7N1CiXWsq38p1g2OSUqnOXJ4/pXS1JtA53wkXP alex@AlexPC
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# План настройки и тестирования поиска по таблице RAG
|
|
||||||
|
|
||||||
## 1. Подготовка таблицы RAG
|
|
||||||
- [x] Убедиться, что таблица RAG создана и содержит пары "вопрос-ответ".
|
|
||||||
- [x] Добавить несколько тестовых записей через UI или напрямую в базу данных.
|
|
||||||
|
|
||||||
## 2. Настройка провайдера эмбеддингов
|
|
||||||
- [x] В настройках ассистента выбрать нужного провайдера (OpenAI, Ollama и др.).
|
|
||||||
- [x] Ввести API-ключ и Base URL (например, для OpenAI: https://api.openai.com/v1).
|
|
||||||
- [x] Сохранить настройки.
|
|
||||||
|
|
||||||
## 3. Проверка настроек ассистента
|
|
||||||
- [x] Убедиться, что выбран актуальный ID таблицы RAG.
|
|
||||||
- [x] Проверить выбранного провайдера эмбеддингов.
|
|
||||||
- [x] Установить порог релевантности (например, 0.95).
|
|
||||||
|
|
||||||
## 4. Проверка backend-логики
|
|
||||||
- [x] Проверить, что в backend (например, в ragService.js) реализован поиск по RAG с использованием выбранного провайдера эмбеддингов.
|
|
||||||
- [x] Убедиться, что используется актуальный ID таблицы и динамический выбор провайдера.
|
|
||||||
- [x] Проверить возможность изменения порога релевантности.
|
|
||||||
|
|
||||||
## 5. Тестирование через UI
|
|
||||||
- [ ] Отправить ассистенту вопрос, который есть в RAG-таблице — убедиться, что ответ возвращается из базы.
|
|
||||||
- [ ] Отправить вопрос, которого нет в таблице — убедиться, что ассистент либо не отвечает, либо использует LLM (по настройкам).
|
|
||||||
|
|
||||||
## 6. Проверка логов backend
|
|
||||||
- [ ] Проверить логи на наличие сообщений о поиске по RAG, найденных совпадениях и выбранном провайдере эмбеддингов.
|
|
||||||
- [ ] В случае ошибок — проанализировать и устранить их.
|
|
||||||
|
|
||||||
## 7. Тестирование через API (опционально)
|
|
||||||
- [ ] Использовать Postman/curl для отправки запросов напрямую к backend.
|
|
||||||
- [ ] Пример запроса:
|
|
||||||
```http
|
|
||||||
POST /api/chat/message
|
|
||||||
{
|
|
||||||
"userId": 137,
|
|
||||||
"message": "Как зовут?"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Автоматизация тестирования (по желанию)
|
|
||||||
- [ ] Написать автотесты (например, на Mocha/Jest), которые будут отправлять вопросы и сверять ответы с ожидаемыми из RAG.
|
|
||||||
|
|
||||||
## 9. Рекомендации
|
|
||||||
- [ ] Для тестов использовать уникальные, простые вопросы и ответы.
|
|
||||||
- [ ] После каждого изменения настроек проводить тестовые запросы.
|
|
||||||
- [ ] Добавить в UI индикатор источника ответа (из базы или сгенерирован).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этапы внедрения сервиса векторного поиска (под ключ)
|
|
||||||
|
|
||||||
- [x] 1. Проектирование и создание структуры Python-сервиса (FastAPI + FAISS)
|
|
||||||
- [x] 2. Реализация REST API: /upsert, /search, /delete, /rebuild, /health
|
|
||||||
- [x] 3. Интеграция с Ollama для генерации эмбеддингов
|
|
||||||
- [x] 4. Dockerfile и docker-compose для сервиса
|
|
||||||
- [ ] 5. Интеграция Node.js backend с новым сервисом (HTTP-клиент)
|
|
||||||
- [ ] 6. Перенос логики поиска из ragService.js на новый сервис
|
|
||||||
- [ ] 7. Тестирование интеграции (ручное и через API)
|
|
||||||
- [ ] 8. Документация по запуску и использованию
|
|
||||||
- [ ] 9. Финальное тестирование через UI и API
|
|
||||||
- [ ] 10. Передача проекта заказчику
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Прогресс:**
|
|
||||||
- Сервис векторного поиска реализован, поддерживает кэширование, интеграцию с Ollama, все основные REST API.
|
|
||||||
- Следующий этап — интеграция с Node.js backend и перенос логики поиска.
|
|
||||||
|
|
||||||
**Если потребуется пример кода или помощь с конкретной реализацией — обращайтесь!**
|
|
||||||
293
md/WEBSSH_SETUP_GUIDE.md
Normal file
293
md/WEBSSH_SETUP_GUIDE.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# Руководство по настройке WEB SSH
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Система WEB SSH позволяет автоматически публиковать локальное Vue.js приложение в интернете через SSH-туннель и NGINX на удаленном сервере.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ Vue.js Frontend │───▶│ WebSSH Agent │───▶│ VPS Server │
|
||||||
|
│ (localhost:5173) │ │ (localhost:12345) │ │ (ваш сервер) │
|
||||||
|
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌─────────────────────┐
|
||||||
|
│ │ NGINX + SSL │
|
||||||
|
│ │ (порт 80/443) │
|
||||||
|
│ └─────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌─────────────────────┐
|
||||||
|
└─────────────▶│ SSH Reverse Tunnel │
|
||||||
|
│ (порт 9000) │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
### Локальная машина
|
||||||
|
- ✅ Node.js 16+
|
||||||
|
- ✅ SSH клиент
|
||||||
|
- ✅ Docker и Docker Compose
|
||||||
|
- ✅ Yarn
|
||||||
|
|
||||||
|
### Удаленный сервер (VPS)
|
||||||
|
- ✅ Ubuntu/Debian или CentOS/RHEL
|
||||||
|
- ✅ SSH доступ с правами sudo/root
|
||||||
|
- ✅ Открытые порты 80, 443, 22
|
||||||
|
- ✅ Домен, указывающий на IP сервера
|
||||||
|
|
||||||
|
## Установка и настройка
|
||||||
|
|
||||||
|
### 1. Запуск основного приложения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонируйте проект
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd DApp-for-Business
|
||||||
|
|
||||||
|
# Запустите приложение
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Убедитесь, что фронтенд доступен
|
||||||
|
curl http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Установка WebSSH Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Перейдите в директорию агента
|
||||||
|
cd webssh-agent
|
||||||
|
|
||||||
|
# Установите зависимости
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Запустите агент
|
||||||
|
npm start
|
||||||
|
# или в фоновом режиме:
|
||||||
|
nohup node agent.js > agent.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Проверка работы агента
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте статус агента
|
||||||
|
curl http://localhost:12345/health
|
||||||
|
|
||||||
|
# Ожидаемый ответ:
|
||||||
|
# {"status":"ok","timestamp":"2025-07-06T18:08:57.789Z","version":"1.0.0","tunnelConnected":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### 1. Подготовка SSH ключей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создайте SSH ключ (если еще нет)
|
||||||
|
ssh-keygen -t rsa -b 4096 -C "webssh@yourdomain.com"
|
||||||
|
|
||||||
|
# Скопируйте публичный ключ на сервер
|
||||||
|
ssh-copy-id -i ~/.ssh/id_rsa.pub user@your-server.com
|
||||||
|
|
||||||
|
# Проверьте подключение
|
||||||
|
ssh -i ~/.ssh/id_rsa user@your-server.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Настройка через веб-интерфейс
|
||||||
|
|
||||||
|
1. Откройте приложение: `http://localhost:5173`
|
||||||
|
2. Перейдите в **Настройки** → **WEB SSH**
|
||||||
|
3. Заполните форму:
|
||||||
|
|
||||||
|
#### Обязательные поля:
|
||||||
|
- **Домен**: `example.com` (ваш домен)
|
||||||
|
- **Email для SSL**: `admin@example.com` (для Let's Encrypt)
|
||||||
|
- **SSH Host/IP**: `192.168.1.100` или `server.example.com`
|
||||||
|
- **SSH Пользователь**: `root` или ваш пользователь
|
||||||
|
- **SSH Приватный ключ**: содержимое файла `~/.ssh/id_rsa`
|
||||||
|
|
||||||
|
#### Дополнительные настройки (заполняются автоматически):
|
||||||
|
- **Локальный порт**: `5173` (порт Vue.js приложения)
|
||||||
|
- **Порт сервера**: `9000` (порт для SSH туннеля)
|
||||||
|
- **SSH порт**: `22` (стандартный SSH порт)
|
||||||
|
|
||||||
|
4. Нажмите **"Опубликовать"**
|
||||||
|
|
||||||
|
### 3. Процесс публикации
|
||||||
|
|
||||||
|
После нажатия "Опубликовать" агент автоматически:
|
||||||
|
|
||||||
|
1. ✅ Подключается к вашему серверу по SSH
|
||||||
|
2. ✅ Устанавливает NGINX и certbot (если не установлены)
|
||||||
|
3. ✅ Создает конфигурацию NGINX для вашего домена
|
||||||
|
4. ✅ Перезапускает NGINX
|
||||||
|
5. ✅ Получает SSL сертификат через Let's Encrypt
|
||||||
|
6. ✅ Создает SSH reverse-туннель
|
||||||
|
7. ✅ Ваше приложение становится доступным по адресу `https://yourdomain.com`
|
||||||
|
|
||||||
|
## Управление туннелем
|
||||||
|
|
||||||
|
### Через веб-интерфейс
|
||||||
|
- **Статус**: отображается в верхней части страницы
|
||||||
|
- **Отключить**: кнопка "Отключить" при активном туннеле
|
||||||
|
- **Логи**: отображаются в нижней части страницы
|
||||||
|
|
||||||
|
### Через API
|
||||||
|
```bash
|
||||||
|
# Статус туннеля
|
||||||
|
curl http://localhost:12345/tunnel/status
|
||||||
|
|
||||||
|
# Создание туннеля
|
||||||
|
curl -X POST http://localhost:12345/tunnel/create \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.com",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"sshHost": "192.168.1.100",
|
||||||
|
"sshUser": "root",
|
||||||
|
"sshKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
|
||||||
|
"localPort": 5173,
|
||||||
|
"serverPort": 9000,
|
||||||
|
"sshPort": 22
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Отключение туннеля
|
||||||
|
curl -X POST http://localhost:12345/tunnel/disconnect
|
||||||
|
```
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### Локальная безопасность
|
||||||
|
- ✅ Агент принимает соединения только с localhost
|
||||||
|
- ✅ SSH ключи хранятся временно и удаляются после отключения
|
||||||
|
- ✅ Все операции логируются
|
||||||
|
|
||||||
|
### Серверная безопасность
|
||||||
|
- ✅ Используется HTTPS с автоматическими SSL сертификатами
|
||||||
|
- ✅ NGINX настроен с безопасными заголовками
|
||||||
|
- ✅ SSH туннель использует приватные ключи
|
||||||
|
|
||||||
|
## Устранение неполадок
|
||||||
|
|
||||||
|
### Агент не запускается
|
||||||
|
```bash
|
||||||
|
# Проверьте порт
|
||||||
|
netstat -tulpn | grep 12345
|
||||||
|
|
||||||
|
# Проверьте логи
|
||||||
|
tail -f webssh-agent/agent.log
|
||||||
|
|
||||||
|
# Убейте процесс и перезапустите
|
||||||
|
pkill -f "node agent.js"
|
||||||
|
nohup node agent.js > agent.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSH соединение не устанавливается
|
||||||
|
```bash
|
||||||
|
# Проверьте SSH ключ
|
||||||
|
ssh -i ~/.ssh/id_rsa user@server
|
||||||
|
|
||||||
|
# Проверьте доступность сервера
|
||||||
|
ping your-server.com
|
||||||
|
|
||||||
|
# Проверьте SSH порт
|
||||||
|
telnet your-server.com 22
|
||||||
|
```
|
||||||
|
|
||||||
|
### NGINX не настраивается
|
||||||
|
```bash
|
||||||
|
# На сервере проверьте NGINX
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl status nginx
|
||||||
|
|
||||||
|
# Проверьте логи
|
||||||
|
sudo tail -f /var/log/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL сертификат не получается
|
||||||
|
```bash
|
||||||
|
# Проверьте DNS
|
||||||
|
nslookup your-domain.com
|
||||||
|
|
||||||
|
# Проверьте доступность домена
|
||||||
|
curl -I http://your-domain.com
|
||||||
|
|
||||||
|
# На сервере проверьте certbot
|
||||||
|
sudo certbot certificates
|
||||||
|
sudo tail -f /var/log/letsencrypt/letsencrypt.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Туннель не работает
|
||||||
|
```bash
|
||||||
|
# Проверьте процесс SSH
|
||||||
|
ps aux | grep ssh
|
||||||
|
|
||||||
|
# Проверьте порты на сервере
|
||||||
|
sudo netstat -tulpn | grep 9000
|
||||||
|
|
||||||
|
# Проверьте логи агента
|
||||||
|
tail -f webssh-agent/agent.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Разработка
|
||||||
|
```bash
|
||||||
|
# Запустите локальное приложение
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Запустите агент
|
||||||
|
cd webssh-agent && npm start
|
||||||
|
|
||||||
|
# Настройте туннель через веб-интерфейс
|
||||||
|
# Ваше приложение доступно по https://yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Демонстрация клиентам
|
||||||
|
```bash
|
||||||
|
# Быстрая публикация для демо
|
||||||
|
# 1. Заполните форму в веб-интерфейсе
|
||||||
|
# 2. Нажмите "Опубликовать"
|
||||||
|
# 3. Отправьте клиенту ссылку https://yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование на мобильных устройствах
|
||||||
|
```bash
|
||||||
|
# Опубликуйте приложение
|
||||||
|
# Теперь можете тестировать на любых устройствах
|
||||||
|
# через https://yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Файловая структура
|
||||||
|
|
||||||
|
```
|
||||||
|
DApp-for-Business/
|
||||||
|
├── webssh-agent/ # Локальный агент
|
||||||
|
│ ├── agent.js # Основной файл агента
|
||||||
|
│ ├── package.json # Зависимости
|
||||||
|
│ ├── install.sh # Установочный скрипт
|
||||||
|
│ └── README.md # Документация агента
|
||||||
|
├── frontend/ # Vue.js приложение
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── views/settings/
|
||||||
|
│ │ │ └── WebSshSettingsView.vue # Страница настроек
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ └── webSshService.js # Сервис для работы с агентом
|
||||||
|
│ └── ...
|
||||||
|
└── docker-compose.yml # Конфигурация Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
Если у вас возникли проблемы:
|
||||||
|
|
||||||
|
1. Проверьте этот документ
|
||||||
|
2. Посмотрите логи агента: `tail -f webssh-agent/agent.log`
|
||||||
|
3. Проверьте статус: `curl http://localhost:12345/health`
|
||||||
|
4. Создайте Issue в репозитории проекта
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT License
|
||||||
111
md/WEB_SSH_TUNNEL_PLAN.md
Normal file
111
md/WEB_SSH_TUNNEL_PLAN.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Автоматизация публикации локального приложения через SSH-туннель и NGINX
|
||||||
|
|
||||||
|
## Описание задачи
|
||||||
|
|
||||||
|
Необходимо реализовать функционал, позволяющий пользователю локального веб-приложения в один клик опубликовать своё приложение в интернете по собственному домену. Для этого используется внешний сервер (VPS) с белым IP и доменом, на котором автоматически настраиваются:
|
||||||
|
- Установка и настройка NGINX для проксирования домена на SSH-туннель
|
||||||
|
- Выпуск и установка SSL-сертификата (Let's Encrypt)
|
||||||
|
- SSH reverse-туннель с сервера на локальное приложение пользователя
|
||||||
|
- (Опционально) Автоматическая установка NGINX и certbot на сервере по SSH силами локального агента
|
||||||
|
|
||||||
|
Пользователь заполняет форму с необходимыми данными, после чего система автоматически выполняет все шаги по настройке инфраструктуры.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура решения
|
||||||
|
|
||||||
|
1. **Локальное приложение** работает в Docker-контейнере на ПК пользователя (порт 5173 проброшен наружу).
|
||||||
|
2. **Локальный агент** (Node.js-приложение) устанавливается на ПК пользователя и позволяет фронтенду запускать команды (например, поднять SSH-туннель, а также настраивать NGINX/SSL на сервере по SSH) в один клик.
|
||||||
|
3. **Агент по SSH подключается к серверу (VPS)** и:
|
||||||
|
- Устанавливает NGINX и certbot (если не установлены)
|
||||||
|
- Создаёт или обновляет конфиг NGINX для указанного домена (проксирует на порт 9000)
|
||||||
|
- Перезапускает NGINX
|
||||||
|
- Выпускает SSL-сертификат через certbot
|
||||||
|
- Проверяет доступность домена по HTTPS
|
||||||
|
- Запускает SSH reverse-туннель (9000:localhost:5173)
|
||||||
|
4. **NGINX** на сервере проксирует домен на порт туннеля (9000).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Необходимые данные для формы
|
||||||
|
|
||||||
|
- Домен (например, myapp.example.com)
|
||||||
|
- Host/IP сервера
|
||||||
|
- Пользователь SSH
|
||||||
|
- SSH-ключ (или пароль)
|
||||||
|
- E-mail для SSL (Let's Encrypt)
|
||||||
|
|
||||||
|
**Поля "Локальный порт приложения", "Порт на сервере для туннеля" и "Порт SSH" скрыты и всегда используются значения по умолчанию:**
|
||||||
|
- Локальный порт: 5173
|
||||||
|
- Порт на сервере: 9000
|
||||||
|
- Порт SSH: 22
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX-поток с локальным агентом (финальный порядок)
|
||||||
|
|
||||||
|
1. Пользователь заходит в локальное веб-приложение и заполняет форму публикации (домен, SSH и т.д.).
|
||||||
|
2. Нажимает кнопку "Опубликовать".
|
||||||
|
3. Агент автоматически скачивается, устанавливается и запускается (без дополнительных действий пользователя).
|
||||||
|
4. После запуска агента фронтенд отправляет параметры публикации на локальный агент (порты и порт SSH подставляются автоматически).
|
||||||
|
5. Агент по SSH подключается к серверу и:
|
||||||
|
- Устанавливает NGINX и certbot (если не установлены)
|
||||||
|
- Создаёт или обновляет конфиг NGINX для домена
|
||||||
|
- Перезапускает NGINX
|
||||||
|
- Выпускает SSL-сертификат через certbot
|
||||||
|
- Запускает SSH reverse-туннель (9000:localhost:5173, порт SSH всегда 22)
|
||||||
|
6. После успешного запуска туннеля приложение становится доступно по домену из интернета.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## План выполнения
|
||||||
|
|
||||||
|
### 1. Фронтенд
|
||||||
|
- [ ] Добавить на страницу настроек интерфейса блок "WEB SSH" с кнопкой "Подробнее"
|
||||||
|
- [ ] Создать отдельную страницу
|
||||||
|
- [ ] Реализовать отправку формы на локальный агент (поля с портами и портом SSH скрыты, используются значения по умолчанию)
|
||||||
|
- [ ] После успешной настройки — отобразить пользователю статус публикации с ссылкой на домен
|
||||||
|
|
||||||
|
### 2. Локальный агент
|
||||||
|
- [ ] Реализовать Node.js-приложение (Web SSH Agent), слушающее локальный порт
|
||||||
|
- [ ] API: запуск/остановка SSH-туннеля, статус, логирование
|
||||||
|
- [ ] Автоматическая установка и настройка NGINX, выпуск SSL-сертификата на сервере по SSH
|
||||||
|
- [ ] Безопасность: принимать команды только с localhost, авторизация по токену
|
||||||
|
- [ ] Инструкция по установке для пользователя (Windows, Mac, Linux)
|
||||||
|
- [ ] (Опционально) Автообновление агента
|
||||||
|
|
||||||
|
### 3. Бэкенд (опционально)
|
||||||
|
- [ ] Реализовать API для логирования, аудита, хранения истории публикаций (если требуется)
|
||||||
|
- [ ] Возвращать статус выполнения и сообщения об ошибках (если используется)
|
||||||
|
|
||||||
|
### 4. Инфраструктура/DevOps
|
||||||
|
- [ ] Проверить, что на сервере открыт порт 9000 для туннеля
|
||||||
|
- [ ] Проверить, что домен указывает на сервер
|
||||||
|
- [ ] Проверить, что на сервере установлен NGINX и certbot
|
||||||
|
- [ ] (Опционально) Добавить systemd unit для автозапуска туннеля
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Важные замечания
|
||||||
|
- Для полной автоматизации публикации в один клик требуется локальный агент, который запускает команды на ПК пользователя и может настраивать сервер по SSH.
|
||||||
|
- В браузере без агента можно только сгенерировать команду для ручного запуска SSH-туннеля.
|
||||||
|
- Все действия на сервере должны выполняться только для авторизованных пользователей (лучше — только для админов)
|
||||||
|
- Необходимо реализовать валидацию всех полей формы
|
||||||
|
- **Используется именно проброшенный наружу порт из Docker (5173)**
|
||||||
|
- **Порт SSH всегда 1024 (по умолчанию), пользователь не видит это поле**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
приложение остаётся локально — туннель обязателен
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример команды для SSH-туннеля
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i /path/to/key -p 22 -R 9000:localhost:5173 user@ваш-сервер
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## С локальным агентом публикация действительно становится "в один клик"!
|
||||||
134
nginx-optimized.conf
Normal file
134
nginx-optimized.conf
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
server {
|
||||||
|
server_name hb3-accelerator.com www.hb3-accelerator.com;
|
||||||
|
|
||||||
|
# Включаем сжатие
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# Кеширование статических файлов
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
proxy_pass http://localhost:9000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header Vary Accept-Encoding;
|
||||||
|
|
||||||
|
# Для WebSocket/HMR:
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Основные страницы
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:9000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Оптимизация для быстрой загрузки
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
proxy_busy_buffers_size 8k;
|
||||||
|
|
||||||
|
# Таймауты
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
|
||||||
|
# Для WebSocket/HMR:
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Для WebSocket соединений
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Таймауты для WebSocket
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API endpoints
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:8000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Оптимизация для API
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
proxy_busy_buffers_size 8k;
|
||||||
|
|
||||||
|
# Таймауты
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
|
||||||
|
# Для WebSocket:
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSL настройки
|
||||||
|
listen 443 ssl http2; # Включаем HTTP/2
|
||||||
|
ssl_certificate /etc/letsencrypt/live/hb3-accelerator.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/hb3-accelerator.com/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# Дополнительные оптимизации SSL (только если не конфликтуют)
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_stapling on;
|
||||||
|
ssl_stapling_verify on;
|
||||||
|
|
||||||
|
# Безопасность
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP to HTTPS redirect
|
||||||
|
server {
|
||||||
|
if ($host = hb3-accelerator.com) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
server_name hb3-accelerator.com www.hb3-accelerator.com;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
359
webssh-agent/agent.js
Normal file
359
webssh-agent/agent.js
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const path = require('path');
|
||||||
|
const chalk = require('chalk');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 12345;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors({
|
||||||
|
origin: ['http://localhost:5173', 'http://localhost:8000'],
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
// Состояние туннеля
|
||||||
|
let tunnelState = {
|
||||||
|
connected: false,
|
||||||
|
domain: null,
|
||||||
|
tunnelId: null,
|
||||||
|
sshProcess: null,
|
||||||
|
config: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Логирование
|
||||||
|
const log = {
|
||||||
|
info: (message) => console.log(chalk.blue('[INFO]'), message),
|
||||||
|
success: (message) => console.log(chalk.green('[SUCCESS]'), message),
|
||||||
|
error: (message) => console.log(chalk.red('[ERROR]'), message),
|
||||||
|
warn: (message) => console.log(chalk.yellow('[WARN]'), message)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Проверка здоровья агента
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
log.info('Health check requested');
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '1.0.0',
|
||||||
|
tunnelConnected: tunnelState.connected
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создание туннеля
|
||||||
|
app.post('/tunnel/create', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { domain, email, sshHost, sshUser, sshKey, localPort, serverPort, sshPort } = req.body;
|
||||||
|
|
||||||
|
log.info(`Создание туннеля для домена: ${domain}`);
|
||||||
|
|
||||||
|
// Валидация входных данных
|
||||||
|
if (!domain || !email || !sshHost || !sshUser || !sshKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Все обязательные поля должны быть заполнены'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если туннель уже подключен, отключаем его
|
||||||
|
if (tunnelState.connected) {
|
||||||
|
log.warn('Отключаем существующий туннель');
|
||||||
|
await disconnectTunnel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем временную директорию для SSH ключей
|
||||||
|
const tempDir = path.join(__dirname, 'temp');
|
||||||
|
await fs.ensureDir(tempDir);
|
||||||
|
|
||||||
|
// Сохраняем SSH ключ во временный файл
|
||||||
|
const keyPath = path.join(tempDir, `ssh_key_${Date.now()}`);
|
||||||
|
let normalizedKey = sshKey
|
||||||
|
.replace(/[ \t]+$/gm, '') // убираем пробелы и табы в конце каждой строки
|
||||||
|
.replace(/\r\n/g, '\n'); // нормализуем переносы строк
|
||||||
|
|
||||||
|
// Гарантируем, что после END нет ничего, кроме перевода строки
|
||||||
|
normalizedKey = normalizedKey.replace(
|
||||||
|
/(-----END OPENSSH PRIVATE KEY-----)[^\n]*$/m,
|
||||||
|
'$1\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
log.info('SSH ключ сохранен, подключаемся к серверу...');
|
||||||
|
|
||||||
|
// Функция для выполнения SSH команд
|
||||||
|
const execSshCommand = (command) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sshArgs = [
|
||||||
|
'-i', keyPath,
|
||||||
|
'-p', (sshPort || 22).toString(),
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
|
`${sshUser}@${sshHost}`,
|
||||||
|
command
|
||||||
|
];
|
||||||
|
|
||||||
|
const sshProcess = spawn('ssh', sshArgs);
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
sshProcess.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshProcess.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshProcess.on('close', (code) => {
|
||||||
|
resolve({ code, stdout, stderr });
|
||||||
|
});
|
||||||
|
|
||||||
|
sshProcess.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info('Подключение к серверу установлено');
|
||||||
|
|
||||||
|
// Проверяем и устанавливаем NGINX и certbot
|
||||||
|
log.info('Проверка и установка NGINX...');
|
||||||
|
const installResult = await execSshCommand('which nginx || (apt-get update && apt-get install -y nginx certbot python3-certbot-nginx)');
|
||||||
|
if (installResult.code !== 0) {
|
||||||
|
log.error('Ошибка установки NGINX: ' + installResult.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание конфигурации NGINX
|
||||||
|
const nginxConfig = `
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ${domain};
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:${serverPort || 9000};
|
||||||
|
proxy_set_header Host \\$host;
|
||||||
|
proxy_set_header X-Real-IP \\$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \\$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \\$scheme;
|
||||||
|
proxy_set_header Upgrade \\$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_cache_bypass \\$http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
log.info('Создание конфигурации NGINX...');
|
||||||
|
await execSshCommand(`echo '${nginxConfig}' > /etc/nginx/sites-available/${domain}`);
|
||||||
|
await execSshCommand(`ln -sf /etc/nginx/sites-available/${domain} /etc/nginx/sites-enabled/`);
|
||||||
|
|
||||||
|
// Проверка и перезагрузка NGINX
|
||||||
|
const nginxTestResult = await execSshCommand('nginx -t');
|
||||||
|
if (nginxTestResult.code !== 0) {
|
||||||
|
log.error('Ошибка конфигурации NGINX: ' + nginxTestResult.stderr);
|
||||||
|
throw new Error('Ошибка конфигурации NGINX: ' + nginxTestResult.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
await execSshCommand('systemctl reload nginx');
|
||||||
|
log.success('NGINX настроен и перезагружен');
|
||||||
|
|
||||||
|
// Получение SSL сертификата
|
||||||
|
log.info('Получение SSL сертификата...');
|
||||||
|
const certbotResult = await execSshCommand(
|
||||||
|
`certbot --nginx -d ${domain} --non-interactive --agree-tos --email ${email} --redirect`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (certbotResult.code !== 0) {
|
||||||
|
log.warn('Предупреждение при получении SSL: ' + certbotResult.stderr);
|
||||||
|
// Продолжаем, даже если SSL не получен
|
||||||
|
} else {
|
||||||
|
log.success('SSL сертификат получен');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание SSH туннеля
|
||||||
|
log.info('Создание SSH туннеля...');
|
||||||
|
const tunnelId = Date.now().toString();
|
||||||
|
const sshArgs = [
|
||||||
|
'-i', keyPath,
|
||||||
|
'-p', (sshPort || 22).toString(),
|
||||||
|
'-R', `${serverPort || 9000}:localhost:${localPort || 5173}`,
|
||||||
|
'-N',
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
|
'-o', 'ServerAliveInterval=60',
|
||||||
|
'-o', 'ServerAliveCountMax=3',
|
||||||
|
`${sshUser}@${sshHost}`
|
||||||
|
];
|
||||||
|
|
||||||
|
const sshProcess = spawn('ssh', sshArgs);
|
||||||
|
|
||||||
|
sshProcess.stdout.on('data', (data) => {
|
||||||
|
log.info('SSH stdout: ' + data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
sshProcess.stderr.on('data', (data) => {
|
||||||
|
log.warn('SSH stderr: ' + data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
sshProcess.on('error', (error) => {
|
||||||
|
log.error('SSH процесс ошибка: ' + error.message);
|
||||||
|
tunnelState.connected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
sshProcess.on('close', (code) => {
|
||||||
|
log.info(`SSH процесс завершен с кодом: ${code}`);
|
||||||
|
tunnelState.connected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Даем время на установку соединения
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
// Обновляем состояние
|
||||||
|
tunnelState = {
|
||||||
|
connected: true,
|
||||||
|
domain,
|
||||||
|
tunnelId,
|
||||||
|
sshProcess,
|
||||||
|
config: { domain, email, sshHost, sshUser, localPort, serverPort, sshPort, keyPath }
|
||||||
|
};
|
||||||
|
|
||||||
|
log.success(`Туннель успешно создан для домена: ${domain}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Туннель успешно создан',
|
||||||
|
tunnelId,
|
||||||
|
domain,
|
||||||
|
url: `https://${domain}`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Ошибка создания туннеля: ' + error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отключение туннеля
|
||||||
|
app.post('/tunnel/disconnect', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await disconnectTunnel();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Ошибка отключения туннеля: ' + error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция отключения туннеля
|
||||||
|
async function disconnectTunnel() {
|
||||||
|
try {
|
||||||
|
if (tunnelState.sshProcess) {
|
||||||
|
tunnelState.sshProcess.kill('SIGTERM');
|
||||||
|
|
||||||
|
// Ждем завершения процесса
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
tunnelState.sshProcess.on('close', resolve);
|
||||||
|
setTimeout(resolve, 5000); // Таймаут 5 сек
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем временный SSH ключ
|
||||||
|
if (tunnelState.config && tunnelState.config.keyPath) {
|
||||||
|
try {
|
||||||
|
await fs.remove(tunnelState.config.keyPath);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Не удалось удалить временный SSH ключ: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousDomain = tunnelState.domain;
|
||||||
|
|
||||||
|
tunnelState = {
|
||||||
|
connected: false,
|
||||||
|
domain: null,
|
||||||
|
tunnelId: null,
|
||||||
|
sshProcess: null,
|
||||||
|
config: null
|
||||||
|
};
|
||||||
|
|
||||||
|
log.success('Туннель отключен');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Туннель для домена ${previousDomain} отключен`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Ошибка при отключении туннеля: ' + error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус туннеля
|
||||||
|
app.get('/tunnel/status', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
connected: tunnelState.connected,
|
||||||
|
domain: tunnelState.domain,
|
||||||
|
tunnelId: tunnelState.tunnelId,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получение логов
|
||||||
|
app.get('/tunnel/logs', (req, res) => {
|
||||||
|
// В реальной реализации здесь можно вернуть логи из файла
|
||||||
|
res.json({
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level: 'info',
|
||||||
|
message: 'Агент запущен и готов к работе'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
log.info('Получен сигнал SIGTERM, завершаем работу...');
|
||||||
|
if (tunnelState.connected) {
|
||||||
|
await disconnectTunnel();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
log.info('Получен сигнал SIGINT, завершаем работу...');
|
||||||
|
if (tunnelState.connected) {
|
||||||
|
await disconnectTunnel();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Запуск сервера
|
||||||
|
app.listen(PORT, 'localhost', () => {
|
||||||
|
log.success(`WebSSH Agent запущен на порту ${PORT}`);
|
||||||
|
log.info('Агент готов к приему команд от фронтенда');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработка необработанных ошибок
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
log.error('Необработанная ошибка: ' + error.message);
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
log.error('Необработанное отклонение промиса: ' + reason);
|
||||||
|
console.error(reason);
|
||||||
|
});
|
||||||
1237
webssh-agent/package-lock.json
generated
Normal file
1237
webssh-agent/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
webssh-agent/package.json
Normal file
33
webssh-agent/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "webssh-agent",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Local SSH tunnel agent for automatic web publishing",
|
||||||
|
"main": "agent.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node agent.js",
|
||||||
|
"dev": "nodemon agent.js",
|
||||||
|
"install-service": "node install-service.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ssh",
|
||||||
|
"tunnel",
|
||||||
|
"nginx",
|
||||||
|
"ssl",
|
||||||
|
"web",
|
||||||
|
"publishing"
|
||||||
|
],
|
||||||
|
"author": "DApp Business",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"fs-extra": "^11.1.1",
|
||||||
|
"chalk": "^4.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user