fix: Resolve IP/domain validation

This commit is contained in:
Whispering Wind
2025-08-06 15:16:27 +03:30
committed by GitHub
parent 05993d013d
commit 133684f4fe
3 changed files with 229 additions and 72 deletions

View File

@ -4,14 +4,33 @@ import sys
import json
import argparse
from pathlib import Path
import re
from ipaddress import ip_address
core_scripts_dir = Path(__file__).resolve().parents[1]
if str(core_scripts_dir) not in sys.path:
sys.path.append(str(core_scripts_dir))
try:
from paths import NODES_JSON_PATH
except ImportError:
NODES_JSON_PATH = Path("/etc/hysteria/nodes.json")
def is_valid_ip_or_domain(value: str) -> bool:
"""Check if the value is a valid IP address or domain name."""
if not value or not value.strip():
return False
value = value.strip()
try:
ip_address(value)
return True
except ValueError:
domain_regex = re.compile(
r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$',
re.IGNORECASE
)
return re.match(domain_regex, value) is not None
def read_nodes():
if not NODES_JSON_PATH.exists():
@ -22,10 +41,8 @@ def read_nodes():
if not content:
return []
return json.loads(content)
except json.JSONDecodeError:
sys.exit(f"Error: Could not decode JSON from {NODES_JSON_PATH}")
except (IOError, OSError) as e:
sys.exit(f"Error reading from {NODES_JSON_PATH}: {e}")
except (json.JSONDecodeError, IOError, OSError) as e:
sys.exit(f"Error reading or parsing {NODES_JSON_PATH}: {e}")
def write_nodes(nodes):
try:
@ -36,17 +53,21 @@ def write_nodes(nodes):
sys.exit(f"Error writing to {NODES_JSON_PATH}: {e}")
def add_node(name: str, ip: str):
if not is_valid_ip_or_domain(ip):
print(f"Error: '{ip}' is not a valid IP address or domain name.", file=sys.stderr)
sys.exit(1)
nodes = read_nodes()
if any(node['name'] == name for node in nodes):
print(f"Error: A node with the name '{name}' already exists.", file=sys.stderr)
sys.exit(1)
if any(node['ip'] == ip for node in nodes):
print(f"Error: A node with the IP '{ip}' already exists.", file=sys.stderr)
print(f"Error: A node with the IP/domain '{ip}' already exists.", file=sys.stderr)
sys.exit(1)
nodes.append({"name": name, "ip": ip})
write_nodes(nodes)
print(f"Successfully added node '{name}' with IP '{ip}'.")
print(f"Successfully added node '{name}' with IP/domain '{ip}'.")
def delete_node(name: str):
nodes = read_nodes()
@ -66,10 +87,10 @@ def list_nodes():
print("No nodes configured.")
return
print(f"{'Name':<20} {'IP Address'}")
print(f"{'-'*20} {'-'*15}")
print(f"{'Name':<30} {'IP Address / Domain'}")
print(f"{'-'*30} {'-'*25}")
for node in sorted(nodes, key=lambda x: x['name']):
print(f"{node['name']:<20} {node['ip']}")
print(f"{node['name']:<30} {node['ip']}")
def main():
parser = argparse.ArgumentParser(description="Manage external node configurations.")
@ -77,7 +98,7 @@ def main():
add_parser = subparsers.add_parser('add', help='Add a new node.')
add_parser.add_argument('--name', type=str, required=True, help='The unique name of the node.')
add_parser.add_argument('--ip', type=str, required=True, help='The IP address of the node.')
add_parser.add_argument('--ip', type=str, required=True, help='The IP address or domain of the node.')
delete_parser = subparsers.add_parser('delete', help='Delete a node by name.')
delete_parser.add_argument('--name', type=str, required=True, help='The name of the node to delete.')

View File

@ -1,24 +1,32 @@
from pydantic import BaseModel, field_validator, ValidationInfo
from ipaddress import IPv4Address, IPv6Address, ip_address
import socket
import re
def validate_ip_or_domain(v: str) -> str | None:
if v is None or v.strip() in ['', 'None']:
return None
v_stripped = v.strip()
try:
ip_address(v_stripped)
return v_stripped
except ValueError:
domain_regex = re.compile(
r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$',
re.IGNORECASE
)
if domain_regex.match(v_stripped):
return v_stripped
raise ValueError(f"'{v_stripped}' is not a valid IP address or domain name.")
class StatusResponse(BaseModel):
ipv4: str | None = None
ipv6: str | None = None
@field_validator('ipv4', 'ipv6', mode='before')
def check_ip_or_domain(cls, v: str, info: ValidationInfo):
if v is None:
return v
try:
ip_address(v)
return v
except ValueError:
try:
socket.getaddrinfo(v, None)
return v
except socket.gaierror:
raise ValueError(f"'{v}' is not a valid IPv4 or IPv6 address or domain name")
def check_local_server_ip(cls, v: str | None):
return validate_ip_or_domain(v)
class EditInputBody(StatusResponse):
pass
@ -28,14 +36,10 @@ class Node(BaseModel):
ip: str
@field_validator('ip', mode='before')
def check_ip(cls, v: str, info: ValidationInfo):
if v is None:
raise ValueError("IP cannot be None")
try:
ip_address(v)
return v
except ValueError:
raise ValueError(f"'{v}' is not a valid IPv4 or IPv6 address")
def check_node_ip(cls, v: str | None):
if not v or not v.strip():
raise ValueError("IP or Domain field cannot be empty.")
return validate_ip_or_domain(v)
class AddNodeBody(Node):
pass

View File

@ -49,7 +49,7 @@
<li class='nav-item'>
<a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab'
aria-controls='change_ip' aria-selected='false'><i class="fas fa-network-wired"></i>
Change IP</a>
IP Management</a>
</li>
<li class='nav-item'>
<a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab'
@ -208,27 +208,68 @@
</button>
</div>
<!-- Change IP Tab -->
<!-- IP Management Tab -->
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
<form id="change_ip_form">
<div class='form-group'>
<label for='ipv4'>IPv4:</label>
<input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4 or Domain'
value="{{ ipv4 or '' }}">
<div class="invalid-feedback">
Please enter a valid IPv4 address or Domain.
</div>
<div class="card card-outline card-primary">
<div class="card-header">
<h3 class="card-title">Local Server IP / Domain</h3>
</div>
<div class='form-group'>
<label for='ipv6'>IPv6:</label>
<input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6 or Domain'
value="{{ ipv6 or '' }}">
<div class="invalid-feedback">
Please enter a valid IPv6 address or Domain.
</div>
<div class="card-body">
<form id="change_ip_form">
<div class='form-group'>
<label for='ipv4'>IPv4 / Domain:</label>
<input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4 or Domain' value="{{ ipv4 or '' }}">
<div class="invalid-feedback">Please enter a valid IPv4 address or Domain.</div>
</div>
<div class='form-group'>
<label for='ipv6'>IPv6 / Domain:</label>
<input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6 or Domain' value="{{ ipv6 or '' }}">
<div class="invalid-feedback">Please enter a valid IPv6 address or Domain.</div>
</div>
<button id="ip_change" type='button' class='btn btn-primary'>Save</button>
</form>
</div>
<button id="ip_change" type='button' class='btn btn-primary'>Save</button>
</form>
</div>
<div class="card card-outline card-secondary mt-4">
<div class="card-header">
<h3 class="card-title">External Nodes</h3>
</div>
<div class="card-body">
<table class="table table-bordered table-striped" id="nodes_table">
<thead>
<tr>
<th>Node Name</th>
<th>IP Address / Domain</th>
<th>Action</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div id="no_nodes_message" class="alert alert-info" style="display: none;">
No external nodes have been configured.
</div>
<hr>
<h5>Add New Node</h5>
<form id="add_node_form" class="form-inline">
<div class="form-group mb-2 mr-sm-2">
<label for="node_name" class="sr-only">Node Name</label>
<input type="text" class="form-control" id="node_name" placeholder="e.g., Node-US">
<div class="invalid-feedback">Please enter a name.</div>
</div>
<div class="form-group mb-2 mr-sm-2">
<label for="node_ip" class="sr-only">IP Address / Domain</label>
<input type="text" class="form-control" id="node_ip" placeholder="e.g., node.example.com">
<div class="invalid-feedback">Please enter a valid IP or domain.</div>
</div>
<button type="button" id="add_node_btn" class="btn btn-success mb-2">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
Add Node
</button>
</form>
</div>
</div>
</div>
<!-- Backup Tab -->
@ -414,7 +455,6 @@
</div>
</div>
<!-- /.card -->
</div>
</div>
</div>
@ -436,6 +476,7 @@
initUI();
fetchDecoyStatus();
fetchObfsStatus();
fetchNodes();
function isValidPath(path) {
if (!path) return false;
@ -458,20 +499,17 @@
return /^[a-zA-Z0-9]+$/.test(subpath);
}
function isValidIPorDomain(input) {
if (!input) return true;
if (input === null || typeof input === 'undefined') return false;
input = input.trim();
if (input === '') return false;
const ipV4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipV6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}([0-9a-fA-F]{1,4}|:)|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
const domainRegex = /^(?!-)(?:[a-zA-Z\d-]{0,62}[a-zA-Z\d]\.){1,126}(?!\d+$)[a-zA-Z\d]{1,63}$/;
const lowerInput = input.toLowerCase();
if (ipV4Regex.test(input)) return true;
if (ipV6Regex.test(input)) return true;
if (domainRegex.test(lowerInput) && !lowerInput.startsWith("http://") && !lowerInput.startsWith("https://")) return true;
return false;
return ipV4Regex.test(input) || ipV6Regex.test(input) || domainRegex.test(lowerInput);
}
function isValidPositiveNumber(value) {
@ -518,12 +556,16 @@
}
}
});
console.log("Success Response:", response);
},
error: function (xhr, status, error) {
let errorMessage = "Something went wrong.";
let errorMessage = "An unexpected error occurred.";
if (xhr.responseJSON && xhr.responseJSON.detail) {
errorMessage = xhr.responseJSON.detail;
const detail = xhr.responseJSON.detail;
if (Array.isArray(detail)) {
errorMessage = detail.map(err => `Error in '${err.loc[1]}': ${err.msg}`).join('\n');
} else if (typeof detail === 'string') {
errorMessage = detail;
}
}
Swal.fire("Error!", errorMessage, "error");
console.error("AJAX Error:", status, error, xhr.responseText);
@ -552,6 +594,10 @@
fieldValid = isValidSubPath(input.val());
} else if (id === 'ipv4' || id === 'ipv6') {
fieldValid = (input.val().trim() === '') ? true : isValidIPorDomain(input.val());
} else if (id === 'node_ip') {
fieldValid = isValidIPorDomain(input.val());
} else if (id === 'node_name') {
fieldValid = input.val().trim() !== "";
} else if (id === 'block_duration' || id === 'max_ips') {
fieldValid = isValidPositiveNumber(input.val());
} else if (id === 'decoy_path') {
@ -572,7 +618,6 @@
return isValid;
}
function initUI() {
$.ajax({
url: "{{ url_for('server_services_status_api') }}",
@ -621,6 +666,83 @@
});
}
function fetchNodes() {
$.ajax({
url: "{{ url_for('get_all_nodes') }}",
type: "GET",
success: function (nodes) {
renderNodes(nodes);
},
error: function(xhr) {
Swal.fire("Error!", "Failed to fetch external nodes list.", "error");
console.error("Error fetching nodes:", xhr.responseText);
}
});
}
function renderNodes(nodes) {
const tableBody = $("#nodes_table tbody");
tableBody.empty();
if (nodes && nodes.length > 0) {
$("#nodes_table").show();
$("#no_nodes_message").hide();
nodes.forEach(node => {
const row = `<tr>
<td>${node.name}</td>
<td>${node.ip}</td>
<td>
<button class="btn btn-xs btn-danger delete-node-btn" data-name="${node.name}">
<i class="fas fa-trash"></i> Delete
</button>
</td>
</tr>`;
tableBody.append(row);
});
} else {
$("#nodes_table").hide();
$("#no_nodes_message").show();
}
}
function addNode() {
if (!validateForm('add_node_form')) return;
const name = $("#node_name").val().trim();
const ip = $("#node_ip").val().trim();
confirmAction(`add the node '${name}'`, function () {
sendRequest(
"{{ url_for('add_node') }}",
"POST",
{ name: name, ip: ip },
`Node '${name}' added successfully!`,
"#add_node_btn",
false,
function() {
$("#node_name").val('');
$("#node_ip").val('');
$("#add_node_form .form-control").removeClass('is-invalid');
fetchNodes();
}
);
});
}
function deleteNode(nodeName) {
confirmAction(`delete the node '${nodeName}'`, function () {
sendRequest(
"{{ url_for('delete_node') }}",
"POST",
{ name: nodeName },
`Node '${nodeName}' deleted successfully!`,
null,
false,
fetchNodes
);
});
}
function updateServiceUI(data) {
const servicesMap = {
"hysteria_telegram_bot": "#telegram_form",
@ -633,7 +755,6 @@
let targetSelector = servicesMap[serviceKey];
let isRunning = data[serviceKey];
if (serviceKey === "hysteria_normal_sub") {
const $normalForm = $("#normal_sub_service_form");
const $normalFormGroups = $normalForm.find(".form-group");
@ -771,7 +892,6 @@
});
}
function setupDecoy() {
if (!validateForm('decoy_form')) return;
const domain = $("#decoy_domain").val();
@ -846,7 +966,6 @@
}
}
function fetchObfsStatus() {
$.ajax({
url: "{{ url_for('check_obfs') }}",
@ -908,7 +1027,6 @@
});
}
function startTelegram() {
if (!validateForm('telegram_form')) return;
const apiToken = $("#telegram_api_token").val();
@ -1197,7 +1315,6 @@
});
});
$("#telegram_start").on("click", startTelegram);
$("#telegram_stop").on("click", stopTelegram);
$("#normal_start").on("click", startNormal);
@ -1215,7 +1332,11 @@
$("#decoy_stop").on("click", stopDecoy);
$("#obfs_enable_btn").on("click", enableObfs);
$("#obfs_disable_btn").on("click", disableObfs);
$("#add_node_btn").on("click", addNode);
$("#nodes_table").on("click", ".delete-node-btn", function() {
const nodeName = $(this).data("name");
deleteNode(nodeName);
});
$('#normal_domain, #sni_domain, #decoy_domain').on('input', function () {
if (isValidDomain($(this).val())) {
@ -1247,9 +1368,20 @@
}
});
$('#ipv4, #ipv6').on('input', function () {
if (isValidIPorDomain($(this).val()) || $(this).val().trim() === '') {
$('#ipv4, #ipv6, #node_ip').on('input', function () {
const isLocalIpField = $(this).attr('id') === 'ipv4' || $(this).attr('id') === 'ipv6';
if (isLocalIpField && $(this).val().trim() === '') {
$(this).removeClass('is-invalid');
} else if (isValidIPorDomain($(this).val())) {
$(this).removeClass('is-invalid');
} else {
$(this).addClass('is-invalid');
}
});
$('#node_name').on('input', function() {
if ($(this).val().trim() !== "") {
$(this).removeClass('is-invalid');
} else {
$(this).addClass('is-invalid');
}