refactor panel new logic

This commit is contained in:
Konstantin Pichugin 2026-01-10 22:51:10 +03:00
parent 7e2f3fda03
commit beac0cdf67
7 changed files with 286 additions and 277 deletions

View file

@ -164,10 +164,14 @@ type ClientEntity struct {
type Node struct { type Node struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
Name string `json:"name" form:"name"` // Node name/identifier Name string `json:"name" form:"name"` // Node name/identifier
Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080") Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080" or "https://...")
ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp
UseTLS bool `json:"useTls" form:"useTls" gorm:"column:use_tls;default:false"` // Whether to use TLS/HTTPS for API calls
CertPath string `json:"certPath" form:"certPath" gorm:"column:cert_path"` // Path to certificate file (optional, for custom CA)
KeyPath string `json:"keyPath" form:"keyPath" gorm:"column:key_path"` // Path to private key file (optional, for custom CA)
InsecureTLS bool `json:"insecureTls" form:"insecureTls" gorm:"column:insecure_tls;default:false"` // Skip certificate verification (not recommended)
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp
} }

View file

@ -150,6 +150,19 @@ func (a *NodeController) updateNode(c *gin.Context) {
if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" { if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" {
node.ApiKey = apiKeyVal node.ApiKey = apiKeyVal
} }
// TLS settings
if useTlsVal, ok := jsonData["useTls"].(bool); ok {
node.UseTLS = useTlsVal
}
if certPathVal, ok := jsonData["certPath"].(string); ok {
node.CertPath = certPathVal
}
if keyPathVal, ok := jsonData["keyPath"].(string); ok {
node.KeyPath = keyPathVal
}
if insecureTlsVal, ok := jsonData["insecureTls"].(bool); ok {
node.InsecureTLS = insecureTlsVal
}
} }
} else { } else {
// Parse as form data (default for web UI) // Parse as form data (default for web UI)
@ -163,6 +176,15 @@ func (a *NodeController) updateNode(c *gin.Context) {
if apiKey := c.PostForm("apiKey"); apiKey != "" { if apiKey := c.PostForm("apiKey"); apiKey != "" {
node.ApiKey = apiKey node.ApiKey = apiKey
} }
// TLS settings
node.UseTLS = c.PostForm("useTls") == "true" || c.PostForm("useTls") == "on"
if certPath := c.PostForm("certPath"); certPath != "" {
node.CertPath = certPath
}
if keyPath := c.PostForm("keyPath"); keyPath != "" {
node.KeyPath = keyPath
}
node.InsecureTLS = c.PostForm("insecureTls") == "true" || c.PostForm("insecureTls") == "on"
} }
// Validate API key if it was changed // Validate API key if it was changed

View file

@ -1,232 +1,149 @@
{{define "modals/nodeModal"}} {{define "modals/nodeModal"}}
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title" :confirm-loading="nodeModal.confirmLoading" <a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title"
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :class="themeSwitcher.currentTheme" :ok-text="nodeModal.okText" :width="600"> @ok="nodeModal.ok" @cancel="nodeModal.cancel" :ok-text="nodeModal.okText" :width="600">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> <a-form layout="vertical">
<a-form-item :label='{{ i18n "pages.nodes.nodeName" }}'> <a-form-item label='{{ i18n "pages.nodes.nodeName" }}'>
<a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input> <a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
</a-form-item> </a-form-item>
<a-form-item :label='{{ i18n "pages.nodes.nodeAddress" }}'> <a-form-item label='{{ i18n "pages.nodes.nodeAddress" }}'>
<a-input v-model.trim="nodeModal.formData.address" placeholder="http://192.168.1.100:8080"></a-input> <a-input v-model.trim="nodeModal.formData.address" placeholder='{{ i18n "pages.nodes.fullUrlHint" }}'></a-input>
<div style="margin-top: 4px; color: #999; font-size: 12px;">
{{ i18n "pages.nodes.fullUrlHint" }}
</div>
</a-form-item> </a-form-item>
<a-form-item :label='{{ i18n "pages.nodes.nodeApiKey" }}'> <a-form-item label='{{ i18n "pages.nodes.nodePort" }}'>
<a-input-password v-model.trim="nodeModal.formData.apiKey" placeholder="{{ i18n "pages.nodes.enterApiKey" }}"></a-input> <a-input-number v-model.number="nodeModal.formData.port" :min="1" :max="65535" :style="{ width: '100%' }"></a-input-number>
<div style="margin-top: 4px; color: #999; font-size: 12px;"> </a-form-item>
{{ i18n "pages.nodes.apiKeyHint" }} <a-form-item label='{{ i18n "pages.nodes.nodeApiKey" }}'>
</div> <a-input-password v-model.trim="nodeModal.formData.apiKey" placeholder='{{ i18n "pages.nodes.enterApiKey" }}'></a-input-password>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
<script> <script>
// Make nodeModal globally available to ensure it works with any base path
const nodeModal = window.nodeModal = { const nodeModal = window.nodeModal = {
visible: false, visible: false,
title: '', title: '',
confirmLoading: false, okText: 'OK',
okText: '{{ i18n "sure" }}',
isEdit: false,
currentNode: null,
confirm: null,
formData: { formData: {
name: '', name: '',
address: '', address: '',
port: 8080,
apiKey: '' apiKey: ''
}, },
ok() { ok() {
// Validate form data // Валидация полей - используем nodeModal напрямую для правильного контекста
if (!this.formData.name || !this.formData.name.trim()) { if (!nodeModal.formData.name || !nodeModal.formData.name.trim()) {
if (typeof app !== 'undefined' && app.$message) {
app.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeName" }}'); Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
}
return; return;
} }
if (!this.formData.address || !this.formData.address.trim()) {
if (!nodeModal.formData.address || !nodeModal.formData.address.trim()) {
if (typeof app !== 'undefined' && app.$message) {
app.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}'); Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
}
return; return;
} }
if (!/^https?:\/\/.+/.test(this.formData.address)) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.validUrl" }}'); if (!nodeModal.formData.apiKey || !nodeModal.formData.apiKey.trim()) {
return; if (typeof app !== 'undefined' && app.$message) {
} app.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
if (!this.formData.apiKey || !this.formData.apiKey.trim()) { } else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterApiKey" }}'); Vue.prototype.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
}
return; return;
} }
this.confirmLoading = true; // Если все поля заполнены, формируем полный адрес с портом
if (this.confirm) { const dataToSend = { ...nodeModal.formData };
const result = this.confirm({ ...this.formData });
// If confirm returns a promise, handle it // Всегда добавляем порт к адресу
if (result && typeof result.then === 'function') { let fullAddress = dataToSend.address.trim();
result.catch(() => { const port = dataToSend.port && dataToSend.port > 0 ? dataToSend.port : 8080;
this.confirmLoading = false;
}); // Правильно добавляем порт к URL
// Парсим URL: http://192.168.0.7 -> http://192.168.0.7:8080
const urlMatch = fullAddress.match(/^(https?:\/\/)([^\/:]+)(\/.*)?$/);
if (urlMatch) {
const protocol = urlMatch[1]; // http:// или https://
const host = urlMatch[2]; // 192.168.0.7
const path = urlMatch[3] || ''; // /path или ''
fullAddress = `${protocol}${host}:${port}${path}`;
} else { } else {
// If not async, reset loading after a short delay // Если не удалось распарсить, просто добавляем порт
setTimeout(() => { fullAddress = `${fullAddress}:${port}`;
this.confirmLoading = false;
}, 100);
}
} else {
this.confirmLoading = false;
}
},
show({ title = '', okText = '{{ i18n "sure" }}', node = null, confirm = (data) => { }, isEdit = false }) {
console.log('[nodeModal.show] START - called with:', { title, okText, node, isEdit });
console.log('[nodeModal.show] this.visible before:', this.visible);
console.log('[nodeModal.show] nodeModalVueInstance:', nodeModalVueInstance);
// Update properties using 'this' like in inbound_modal
this.title = title;
this.okText = okText;
this.isEdit = isEdit;
this.confirm = confirm;
console.log('[nodeModal.show] Properties updated:', { title: this.title, okText: this.okText, isEdit: this.isEdit });
if (node) {
this.currentNode = node;
this.formData = {
name: node.name || '',
address: node.address || '',
apiKey: node.apiKey || ''
};
console.log('[nodeModal.show] Node data set:', this.formData);
} else {
this.currentNode = null;
this.formData = {
name: '',
address: '',
apiKey: ''
};
console.log('[nodeModal.show] Form data reset (new node)');
} }
// Set visible - Vue will track this since nodeModal is in Vue instance data // Удаляем порт из данных, так как он теперь в адресе
console.log('[nodeModal.show] Setting this.visible = true'); delete dataToSend.port;
this.visible = true; dataToSend.address = fullAddress;
console.log('[nodeModal.show] this.visible after setting:', this.visible);
// Check Vue instance // Вызываем confirm с объединенным адресом
if (nodeModalVueInstance) { if (nodeModal.confirm) {
console.log('[nodeModal.show] Vue instance exists'); nodeModal.confirm(dataToSend);
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal:', nodeModalVueInstance.nodeModal);
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
console.log('[nodeModal.show] nodeModalVueInstance.$el:', nodeModalVueInstance.$el);
} else {
console.warn('[nodeModal.show] WARNING - Vue instance does not exist!');
} }
nodeModal.visible = false;
// Check DOM element
const modalElement = document.getElementById('node-modal');
console.log('[nodeModal.show] Modal element in DOM:', modalElement);
if (modalElement) {
console.log('[nodeModal.show] Modal element classes:', modalElement.className);
console.log('[nodeModal.show] Modal element style.display:', modalElement.style.display);
const computedStyle = window.getComputedStyle(modalElement);
console.log('[nodeModal.show] Modal element computed display:', computedStyle.display);
console.log('[nodeModal.show] Modal element computed visibility:', computedStyle.visibility);
console.log('[nodeModal.show] Modal element computed opacity:', computedStyle.opacity);
console.log('[nodeModal.show] Modal element computed z-index:', computedStyle.zIndex);
// Check for Ant Design modal root
const modalRoot = document.querySelector('.ant-modal-root');
console.log('[nodeModal.show] Ant Design modal root exists:', !!modalRoot);
if (modalRoot) {
console.log('[nodeModal.show] Modal root style.display:', window.getComputedStyle(modalRoot).display);
const modalWrap = modalRoot.querySelector('.ant-modal-wrap');
console.log('[nodeModal.show] Modal wrap exists:', !!modalWrap);
if (modalWrap) {
console.log('[nodeModal.show] Modal wrap style.display:', window.getComputedStyle(modalWrap).display);
const modalInWrap = modalWrap.querySelector('#node-modal');
console.log('[nodeModal.show] Modal #node-modal in wrap:', !!modalInWrap);
}
}
} else {
console.error('[nodeModal.show] ERROR - Modal element #node-modal not found in DOM!');
}
// Use nextTick to check after Vue updates
if (nodeModalVueInstance) {
nodeModalVueInstance.$nextTick(() => {
console.log('[nodeModal.show] After $nextTick - nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
const modalElementAfter = document.getElementById('node-modal');
if (modalElementAfter) {
const modalRootAfter = document.querySelector('.ant-modal-root');
if (modalRootAfter) {
console.log('[nodeModal.show] After $nextTick - Modal root display:', window.getComputedStyle(modalRootAfter).display);
}
}
});
}
console.log('[nodeModal.show] END');
}, },
cancel() { cancel() {
nodeModal.visible = false; this.visible = false;
// Reset form data },
nodeModal.formData = { show({ title = '', okText = 'OK', node = null, confirm = (data) => { }, isEdit = false }) {
this.title = title;
this.okText = okText;
this.confirm = confirm;
if (node) {
// Извлекаем адрес и порт из полного URL
let address = node.address || '';
let port = 8080;
// Всегда извлекаем порт из адреса, если он там есть
if (address) {
const urlMatch = address.match(/^(https?:\/\/[^\/:]+)(:(\d+))?(\/.*)?$/);
if (urlMatch) {
// Убираем порт из адреса для отображения
const protocol = urlMatch[1].match(/^(https?:\/\/)/)[1];
const host = urlMatch[1].replace(/^https?:\/\//, '');
const path = urlMatch[4] || '';
address = `${protocol}${host}${path}`;
// Если порт был в адресе, извлекаем его
if (urlMatch[3]) {
port = parseInt(urlMatch[3], 10);
}
}
}
this.formData = {
name: node.name || '',
address: address,
port: port,
apiKey: node.apiKey || ''
};
} else {
this.formData = {
name: '', name: '',
address: '', address: '',
port: 8080,
apiKey: '' apiKey: ''
}; };
}
this.visible = true;
}, },
close() { close() {
nodeModal.visible = false; this.visible = false;
nodeModal.confirmLoading = false;
},
loading(loading = true) {
this.confirmLoading = loading;
} }
}; };
// Store Vue instance globally to ensure methods are always accessible const nodeModalVueInstance = new Vue({
let nodeModalVueInstance = null;
// Create Vue instance after main app is ready
window.initNodeModalVue = function initNodeModalVue() {
if (nodeModalVueInstance) {
return; // Already initialized
}
const modalElement = document.getElementById('node-modal');
if (!modalElement) {
setTimeout(initNodeModalVue, 50);
return;
}
try {
nodeModalVueInstance = new Vue({
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
el: '#node-modal', el: '#node-modal',
data: { data: {
nodeModal: nodeModal, nodeModal: nodeModal
get themeSwitcher() {
// Try to get themeSwitcher from window or global scope
if (typeof window !== 'undefined' && window.themeSwitcher) {
return window.themeSwitcher;
}
if (typeof themeSwitcher !== 'undefined') {
return themeSwitcher;
}
// Fallback to a simple object if themeSwitcher is not available
return { currentTheme: 'light' };
}
} }
}); });
window.nodeModalVueInstance = nodeModalVueInstance;
} catch (error) {
console.error('[nodeModal init] ERROR creating Vue instance:', error);
}
};
// Wait for DOM and main app to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(window.initNodeModalVue, 100);
});
} else {
setTimeout(window.initNodeModalVue, 100);
}
</script> </script>
{{end}} {{end}}

View file

@ -12,12 +12,7 @@
<h2>{{ i18n "pages.nodes.title" }}</h2> <h2>{{ i18n "pages.nodes.title" }}</h2>
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<h3>{{ i18n "pages.nodes.addNewNode" }}</h3> <a-button type="primary" icon="plus" @click="openAddNode">{{ i18n "pages.nodes.addNewNode" }}</a-button>
<a-input id="node-name" placeholder='{{ i18n "pages.nodes.nodeName" }}' style="width: 200px; margin-right: 10px;"></a-input>
<a-input id="node-address" placeholder='{{ i18n "pages.nodes.nodeAddress" }} (http://192.168.1.100)' style="width: 250px; margin-right: 10px;"></a-input>
<a-input id="node-port" placeholder='{{ i18n "pages.nodes.nodePort" }} (8080)' type="number" style="width: 120px; margin-right: 10px;"></a-input>
<a-input-password id="node-apikey" placeholder='{{ i18n "pages.nodes.nodeApiKey" }}' style="width: 200px; margin-right: 10px;"></a-input-password>
<a-button type="primary" onclick="addNode()">{{ i18n "pages.nodes.addNode" }}</a-button>
</div> </div>
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
@ -109,6 +104,7 @@
<script src="{{ .base_path }}assets/js/model/node.js?{{ .cur_ver }}"></script> <script src="{{ .base_path }}assets/js/model/node.js?{{ .cur_ver }}"></script>
{{template "component/aSidebar" .}} {{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}} {{template "component/aThemeSwitch" .}}
{{template "modals/nodeModal"}}
<script> <script>
const columns = [{ const columns = [{
title: "ID", title: "ID",
@ -228,7 +224,7 @@
this.reloadNode(node.id); this.reloadNode(node.id);
break; break;
case 'edit': case 'edit':
this.startEditNodeName(node); this.editNode(node);
break; break;
case 'delete': case 'delete':
this.deleteNode(node.id); this.deleteNode(node.id);
@ -388,6 +384,63 @@
} finally { } finally {
this.reloadingAll = false; this.reloadingAll = false;
} }
},
openAddNode() {
if (typeof window.nodeModal !== 'undefined') {
window.nodeModal.show({
title: '{{ i18n "pages.nodes.addNewNode" }}',
okText: '{{ i18n "create" }}',
confirm: async (nodeData) => {
await this.submitNode(nodeData, false);
},
isEdit: false
});
} else {
console.error('[openAddNode] ERROR: nodeModal is not defined!');
}
},
editNode(node) {
if (typeof window.nodeModal !== 'undefined') {
// Load full node data including TLS settings
HttpUtil.get(`/panel/node/get/${node.id}`).then(msg => {
if (msg && msg.success && msg.obj) {
window.nodeModal.show({
title: '{{ i18n "pages.nodes.editNode" }}',
okText: '{{ i18n "update" }}',
node: msg.obj,
confirm: async (nodeData) => {
await this.submitNode(nodeData, true, node.id);
},
isEdit: true
});
} else {
app.$message.error('{{ i18n "pages.nodes.loadError" }}');
}
}).catch(e => {
console.error("Failed to load node:", e);
app.$message.error('{{ i18n "pages.nodes.loadError" }}');
});
} else {
console.error('[editNode] ERROR: nodeModal is not defined!');
}
},
async submitNode(nodeData, isEdit, nodeId = null) {
try {
const url = isEdit ? `/panel/node/update/${nodeId}` : '/panel/node/add';
const msg = await HttpUtil.post(url, nodeData);
if (msg && msg.success) {
app.$message.success(isEdit ? '{{ i18n "pages.nodes.updateSuccess" }}' : '{{ i18n "pages.nodes.addSuccess" }}');
await this.loadNodes();
if (window.nodeModal) {
window.nodeModal.close();
}
} else {
app.$message.error((msg && msg.msg) || (isEdit ? '{{ i18n "pages.nodes.updateError" }}' : '{{ i18n "pages.nodes.addError" }}'));
}
} catch (e) {
console.error(`Failed to ${isEdit ? 'update' : 'add'} node:`, e);
app.$message.error(isEdit ? '{{ i18n "pages.nodes.updateError" }}' : '{{ i18n "pages.nodes.addError" }}');
}
} }
}, },
async mounted() { async mounted() {
@ -395,72 +448,5 @@
} }
}); });
async function addNode() {
const name = document.getElementById('node-name').value.trim();
const address = document.getElementById('node-address').value.trim();
const port = document.getElementById('node-port').value.trim();
const apiKey = document.getElementById('node-apikey').value;
if (!name || !address || !port || !apiKey) {
app.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
return;
}
// Validate address format
if (!address.match(/^https?:\/\//)) {
app.$message.error('{{ i18n "pages.nodes.validUrl" }}');
return;
}
// Validate port
const portNum = parseInt(port);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
app.$message.error('{{ i18n "pages.nodes.validPort" }}');
return;
}
// Construct full address
const fullAddress = `${address}:${port}`;
// Check for duplicate nodes
const existingNodes = app.$data.nodes || [];
const duplicate = existingNodes.find(node => {
try {
const nodeUrl = new URL(node.address);
const newUrl = new URL(fullAddress);
// Compare protocol, hostname, and port
const nodePort = nodeUrl.port || (nodeUrl.protocol === 'https:' ? '443' : '80');
const newPort = newUrl.port || (newUrl.protocol === 'https:' ? '443' : '80');
return nodeUrl.protocol === newUrl.protocol &&
nodeUrl.hostname === newUrl.hostname &&
nodePort === newPort;
} catch (e) {
// If URL parsing fails, do simple string comparison
return node.address === fullAddress;
}
});
if (duplicate) {
app.$message.error('{{ i18n "pages.nodes.duplicateNode" }}');
return;
}
try {
const msg = await HttpUtil.post('/panel/node/add', { name, address: fullAddress, apiKey });
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.addSuccess" }}');
document.getElementById('node-name').value = '';
document.getElementById('node-address').value = '';
document.getElementById('node-port').value = '';
document.getElementById('node-apikey').value = '';
app.loadNodes();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.addError" }}');
}
} catch (error) {
console.error('Error:', error);
app.$message.error('{{ i18n "pages.nodes.addError" }}');
}
}
</script> </script>
{{template "page/body_end" .}} {{template "page/body_end" .}}

View file

@ -3,10 +3,13 @@ package service
import ( import (
"bytes" "bytes"
"crypto/tls"
"crypto/x509"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@ -70,6 +73,16 @@ func (s *NodeService) UpdateNode(node *model.Node) error {
updates["api_key"] = node.ApiKey updates["api_key"] = node.ApiKey
} }
// Update TLS settings if provided
updates["use_tls"] = node.UseTLS
if node.CertPath != "" {
updates["cert_path"] = node.CertPath
}
if node.KeyPath != "" {
updates["key_path"] = node.KeyPath
}
updates["insecure_tls"] = node.InsecureTLS
// Update status and last_check if provided (these are usually set by health checks, not user edits) // Update status and last_check if provided (these are usually set by health checks, not user edits)
if node.Status != "" && node.Status != existingNode.Status { if node.Status != "" && node.Status != existingNode.Status {
updates["status"] = node.Status updates["status"] = node.Status
@ -117,10 +130,53 @@ func (s *NodeService) CheckNodeHealth(node *model.Node) error {
return s.UpdateNode(node) return s.UpdateNode(node)
} }
// createHTTPClient creates an HTTP client configured for the node's TLS settings.
func (s *NodeService) createHTTPClient(node *model.Node, timeout time.Duration) (*http.Client, error) {
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: node.InsecureTLS,
},
}
// If custom certificates are provided, load them
if node.UseTLS && node.CertPath != "" {
// Load custom CA certificate
cert, err := os.ReadFile(node.CertPath)
if err != nil {
return nil, fmt.Errorf("failed to read certificate file: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(cert) {
return nil, fmt.Errorf("failed to parse certificate")
}
transport.TLSClientConfig.RootCAs = caCertPool
transport.TLSClientConfig.InsecureSkipVerify = false // Use custom CA
}
// If custom key is provided, load client certificate
if node.UseTLS && node.KeyPath != "" && node.CertPath != "" {
// Load client certificate (cert + key)
clientCert, err := tls.LoadX509KeyPair(node.CertPath, node.KeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
transport.TLSClientConfig.Certificates = []tls.Certificate{clientCert}
}
return &http.Client{
Timeout: timeout,
Transport: transport,
}, nil
}
// CheckNodeStatus performs a health check on a given node. // CheckNodeStatus performs a health check on a given node.
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) { func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) {
client := &http.Client{ client, err := s.createHTTPClient(node, 5*time.Second)
Timeout: 5 * time.Second, if err != nil {
return "error", err
} }
url := fmt.Sprintf("%s/health", node.Address) url := fmt.Sprintf("%s/health", node.Address)
@ -226,8 +282,9 @@ type NodeClientTraffic struct {
// GetNodeStats retrieves traffic and online clients statistics from a node. // GetNodeStats retrieves traffic and online clients statistics from a node.
func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) { func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) {
client := &http.Client{ client, err := s.createHTTPClient(node, 10*time.Second)
Timeout: 10 * time.Second, if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
} }
url := fmt.Sprintf("%s/api/v1/stats", node.Address) url := fmt.Sprintf("%s/api/v1/stats", node.Address)
@ -419,8 +476,9 @@ func (s *NodeService) UnassignInboundFromNode(inboundId int) error {
// ApplyConfigToNode sends XRAY configuration to a node. // ApplyConfigToNode sends XRAY configuration to a node.
func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error { func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error {
client := &http.Client{ client, err := s.createHTTPClient(node, 30*time.Second)
Timeout: 30 * time.Second, if err != nil {
return fmt.Errorf("failed to create HTTP client: %w", err)
} }
url := fmt.Sprintf("%s/api/v1/apply-config", node.Address) url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
@ -448,8 +506,9 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) err
// ReloadNode reloads XRAY on a specific node. // ReloadNode reloads XRAY on a specific node.
func (s *NodeService) ReloadNode(node *model.Node) error { func (s *NodeService) ReloadNode(node *model.Node) error {
client := &http.Client{ client, err := s.createHTTPClient(node, 30*time.Second)
Timeout: 30 * time.Second, if err != nil {
return fmt.Errorf("failed to create HTTP client: %w", err)
} }
url := fmt.Sprintf("%s/api/v1/reload", node.Address) url := fmt.Sprintf("%s/api/v1/reload", node.Address)
@ -476,8 +535,9 @@ func (s *NodeService) ReloadNode(node *model.Node) error {
// ForceReloadNode forcefully reloads XRAY on a specific node (even if hung). // ForceReloadNode forcefully reloads XRAY on a specific node (even if hung).
func (s *NodeService) ForceReloadNode(node *model.Node) error { func (s *NodeService) ForceReloadNode(node *model.Node) error {
client := &http.Client{ client, err := s.createHTTPClient(node, 30*time.Second)
Timeout: 30 * time.Second, if err != nil {
return fmt.Errorf("failed to create HTTP client: %w", err)
} }
url := fmt.Sprintf("%s/api/v1/force-reload", node.Address) url := fmt.Sprintf("%s/api/v1/force-reload", node.Address)
@ -539,8 +599,9 @@ func (s *NodeService) ReloadAllNodes() error {
// ValidateApiKey validates the API key by making a test request to the node. // ValidateApiKey validates the API key by making a test request to the node.
func (s *NodeService) ValidateApiKey(node *model.Node) error { func (s *NodeService) ValidateApiKey(node *model.Node) error {
client := &http.Client{ client, err := s.createHTTPClient(node, 5*time.Second)
Timeout: 5 * time.Second, if err != nil {
return fmt.Errorf("failed to create HTTP client: %w", err)
} }
// First, check if node is reachable via health endpoint // First, check if node is reachable via health endpoint
@ -593,8 +654,9 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error {
// GetNodeStatus retrieves the status of a node. // GetNodeStatus retrieves the status of a node.
func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) { func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) {
client := &http.Client{ client, err := s.createHTTPClient(node, 5*time.Second)
Timeout: 5 * time.Second, if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
} }
url := fmt.Sprintf("%s/api/v1/status", node.Address) url := fmt.Sprintf("%s/api/v1/status", node.Address)

View file

@ -622,7 +622,7 @@
"validUrl" = "Must be a valid URL (http:// or https://)" "validUrl" = "Must be a valid URL (http:// or https://)"
"validPort" = "Port must be a number between 1 and 65535" "validPort" = "Port must be a number between 1 and 65535"
"duplicateNode" = "A node with this address and port already exists" "duplicateNode" = "A node with this address and port already exists"
"fullUrlHint" = "Full URL to node API (e.g., http://192.168.1.100:8080)" "fullUrlHint" = "Full URL to node API (e.g., http://192.168.1.100 or domain)"
"enterApiKey" = "Please enter API key" "enterApiKey" = "Please enter API key"
"apiKeyHint" = "API key configured on the node (NODE_API_KEY environment variable)" "apiKeyHint" = "API key configured on the node (NODE_API_KEY environment variable)"
"leaveEmptyToKeep" = "leave empty to keep current" "leaveEmptyToKeep" = "leave empty to keep current"
@ -644,6 +644,15 @@
"reloadError" = "Failed to reload node" "reloadError" = "Failed to reload node"
"reloadAllSuccess" = "All nodes reloaded successfully" "reloadAllSuccess" = "All nodes reloaded successfully"
"reloadAllError" = "Failed to reload some nodes" "reloadAllError" = "Failed to reload some nodes"
"tlsSettings" = "TLS/HTTPS Settings"
"useTls" = "Use TLS/HTTPS"
"useTlsHint" = "Enable TLS/HTTPS for API calls to this node"
"certPath" = "Certificate Path"
"certPathHint" = "Path to CA certificate file (optional, for custom CA)"
"keyPath" = "Private Key Path"
"keyPathHint" = "Path to private key file (optional, for client certificate)"
"insecureTls" = "Skip Certificate Verification"
"insecureTlsHint" = "⚠️ Not recommended: Skip TLS certificate verification (insecure)"
[pages.nodes.toasts] [pages.nodes.toasts]
"createSuccess" = "Node created successfully" "createSuccess" = "Node created successfully"

View file

@ -622,7 +622,7 @@
"validUrl" = "Должен быть действительным URL (http:// или https://)" "validUrl" = "Должен быть действительным URL (http:// или https://)"
"validPort" = "Порт должен быть числом от 1 до 65535" "validPort" = "Порт должен быть числом от 1 до 65535"
"duplicateNode" = "Нода с таким адресом и портом уже существует" "duplicateNode" = "Нода с таким адресом и портом уже существует"
"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100:8080)" "fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100 или домен)"
"enterApiKey" = "Пожалуйста, введите API ключ" "enterApiKey" = "Пожалуйста, введите API ключ"
"apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)" "apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)"
"leaveEmptyToKeep" = "оставьте пустым чтобы не менять" "leaveEmptyToKeep" = "оставьте пустым чтобы не менять"
@ -644,6 +644,15 @@
"reloadError" = "Не удалось перезагрузить ноду" "reloadError" = "Не удалось перезагрузить ноду"
"reloadAllSuccess" = "Все ноды успешно перезагружены" "reloadAllSuccess" = "Все ноды успешно перезагружены"
"reloadAllError" = "Не удалось перезагрузить некоторые ноды" "reloadAllError" = "Не удалось перезагрузить некоторые ноды"
"tlsSettings" = "Настройки TLS/HTTPS"
"useTls" = "Использовать TLS/HTTPS"
"useTlsHint" = "Включить TLS/HTTPS для API вызовов к этой ноде"
"certPath" = "Путь к сертификату"
"certPathHint" = "Путь к файлу сертификата CA (опционально, для кастомного CA)"
"keyPath" = "Путь к приватному ключу"
"keyPathHint" = "Путь к файлу приватного ключа (опционально, для клиентского сертификата)"
"insecureTls" = "Пропустить проверку сертификата"
"insecureTlsHint" = "⚠️ Не рекомендуется: пропустить проверку TLS сертификата (небезопасно)"
[pages.nodes.toasts] [pages.nodes.toasts]
"createSuccess" = "Нода успешно создана" "createSuccess" = "Нода успешно создана"