feat(api): Add external node management endpoints

This commit is contained in:
Whispering Wind
2025-08-06 14:45:35 +03:30
committed by GitHub
parent 1ae8efa5f9
commit 05993d013d
5 changed files with 103 additions and 28 deletions

View File

@ -14,6 +14,7 @@ CONFIG_FILE = '/etc/hysteria/config.json'
CONFIG_ENV_FILE = '/etc/hysteria/.configs.env' CONFIG_ENV_FILE = '/etc/hysteria/.configs.env'
WEBPANEL_ENV_FILE = '/etc/hysteria/core/scripts/webpanel/.env' WEBPANEL_ENV_FILE = '/etc/hysteria/core/scripts/webpanel/.env'
NORMALSUB_ENV_FILE = '/etc/hysteria/core/scripts/normalsub/.env' NORMALSUB_ENV_FILE = '/etc/hysteria/core/scripts/normalsub/.env'
NODES_JSON_PATH = "/etc/hysteria/nodes.json"
class Command(Enum): class Command(Enum):

View File

@ -1,44 +1,43 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse from ..schema.response import DetailResponse
import json
import os
from ..schema.config.ip import (
from ..schema.config.ip import EditInputBody, StatusResponse EditInputBody,
StatusResponse,
AddNodeBody,
DeleteNodeBody,
NodeListResponse
)
import cli_api import cli_api
router = APIRouter() router = APIRouter()
@router.get('/get', response_model=StatusResponse, summary='Get IP Status') @router.get('/get', response_model=StatusResponse, summary='Get Local Server IP Status')
async def get_ip_api(): async def get_ip_api():
""" """
Retrieves the current status of IP addresses. Retrieves the current status of the main server's IP addresses.
Returns: Returns:
StatusResponse: A response model containing the current IP address details. StatusResponse: A response model containing the current IP address details.
Raises:
HTTPException: If the IP status is not available (404) or if there is an error processing the request (400).
""" """
try: try:
ipv4, ipv6 = cli_api.get_ip_address() ipv4, ipv6 = cli_api.get_ip_address()
if ipv4 or ipv6: return StatusResponse(ipv4=ipv4, ipv6=ipv6)
return StatusResponse(ipv4=ipv4, ipv6=ipv6) # type: ignore
raise HTTPException(status_code=404, detail='IP status not available.')
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}') raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/add', response_model=DetailResponse, summary='Add IP') @router.get('/add', response_model=DetailResponse, summary='Detect and Add Local Server IP')
async def add_ip_api(): async def add_ip_api():
""" """
Adds the auto-detected IP addresses to the .configs.env file. Adds the auto-detected IP addresses to the .configs.env file.
Returns: Returns:
A DetailResponse with a message indicating the IP addresses were added successfully. A DetailResponse with a message indicating the IP addresses were added successfully.
Raises:
HTTPException: if an error occurs while adding the IP addresses.
""" """
try: try:
cli_api.add_ip_address() cli_api.add_ip_address()
@ -47,19 +46,13 @@ async def add_ip_api():
raise HTTPException(status_code=400, detail=f'Error: {str(e)}') raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.post('/edit', response_model=DetailResponse, summary='Edit IP') @router.post('/edit', response_model=DetailResponse, summary='Edit Local Server IP')
async def edit_ip_api(body: EditInputBody): async def edit_ip_api(body: EditInputBody):
""" """
Edits the IP addresses in the .configs.env file. Edits the main server's IP addresses in the .configs.env file.
Args: Args:
body: An instance of EditInputBody containing the new IPv4 and/or IPv6 addresses. body: An instance of EditInputBody containing the new IPv4 and/or IPv6 addresses.
Returns:
A DetailResponse with a message indicating the IP addresses were edited successfully.
Raises:
HTTPException: if an error occurs while editing the IP addresses.
""" """
try: try:
if not body.ipv4 and not body.ipv6: if not body.ipv4 and not body.ipv6:
@ -69,3 +62,54 @@ async def edit_ip_api(body: EditInputBody):
return DetailResponse(detail='IP address edited successfully.') return DetailResponse(detail='IP address edited successfully.')
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}') raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/nodes', response_model=NodeListResponse, summary='Get All External Nodes')
async def get_all_nodes():
"""
Retrieves the list of all configured external nodes.
Returns:
A list of node objects, each containing a name and an IP.
"""
if not os.path.exists(cli_api.NODES_JSON_PATH):
return []
try:
with open(cli_api.NODES_JSON_PATH, 'r') as f:
content = f.read()
if not content:
return []
return json.loads(content)
except (json.JSONDecodeError, IOError) as e:
raise HTTPException(status_code=500, detail=f"Failed to read or parse nodes file: {e}")
@router.post('/nodes/add', response_model=DetailResponse, summary='Add External Node')
async def add_node(body: AddNodeBody):
"""
Adds a new external node to the configuration.
Args:
body: Request body containing the name and IP of the node.
"""
try:
cli_api.add_node(body.name, body.ip)
return DetailResponse(detail=f"Node '{body.name}' added successfully.")
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post('/nodes/delete', response_model=DetailResponse, summary='Delete External Node')
async def delete_node(body: DeleteNodeBody):
"""
Deletes an external node from the configuration by its name.
Args:
body: Request body containing the name of the node to delete.
"""
try:
cli_api.delete_node(body.name)
return DetailResponse(detail=f"Node '{body.name}' deleted successfully.")
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@ -21,4 +21,26 @@ class StatusResponse(BaseModel):
raise ValueError(f"'{v}' is not a valid IPv4 or IPv6 address or domain name") raise ValueError(f"'{v}' is not a valid IPv4 or IPv6 address or domain name")
class EditInputBody(StatusResponse): class EditInputBody(StatusResponse):
pass pass
class Node(BaseModel):
name: str
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")
class AddNodeBody(Node):
pass
class DeleteNodeBody(BaseModel):
name: str
NodeListResponse = list[Node]

View File

@ -1,4 +1,4 @@
from typing import Optional from typing import Optional, List
from pydantic import BaseModel, RootModel from pydantic import BaseModel, RootModel
@ -38,8 +38,14 @@ class EditUserInputBody(BaseModel):
renew_creation_date: bool = False renew_creation_date: bool = False
blocked: bool = False blocked: bool = False
class NodeUri(BaseModel):
name: str
uri: str
class UserUriResponse(BaseModel): class UserUriResponse(BaseModel):
username: str username: str
ipv4: str | None = None ipv4: Optional[str] = None
ipv6: str | None = None ipv6: Optional[str] = None
normal_sub: str | None = None nodes: Optional[List[NodeUri]] = []
normal_sub: Optional[str] = None
error: Optional[str] = None

View File

@ -178,10 +178,12 @@ async def show_user_uri_api(username: str):
uri_data_list = cli_api.show_user_uri_json([username]) uri_data_list = cli_api.show_user_uri_json([username])
if not uri_data_list: if not uri_data_list:
raise HTTPException(status_code=404, detail=f'URI for user {username} not found.') raise HTTPException(status_code=404, detail=f'URI for user {username} not found.')
uri_data = uri_data_list[0] uri_data = uri_data_list[0]
if uri_data.get('error'): if uri_data.get('error'):
raise HTTPException(status_code=404, detail=f"{uri_data['error']}") raise HTTPException(status_code=404, detail=f"{uri_data['error']}")
return uri_data
return UserUriResponse(**uri_data)
except cli_api.ScriptNotFoundError as e: except cli_api.ScriptNotFoundError as e:
raise HTTPException(status_code=500, detail=f'Server script error: {str(e)}') raise HTTPException(status_code=500, detail=f'Server script error: {str(e)}')
except cli_api.CommandExecutionError as e: except cli_api.CommandExecutionError as e: