2024-12-15 14:40:03 +01:00
|
|
|
"""Server Module."""
|
|
|
|
|
2025-06-03 08:30:37 +02:00
|
|
|
import ipaddress
|
|
|
|
import re
|
2025-01-22 23:47:28 +01:00
|
|
|
import time
|
|
|
|
from typing import Optional, Union
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-01-22 23:47:28 +01:00
|
|
|
import psutil
|
2024-12-15 14:40:03 +01:00
|
|
|
from pydantic import Field, IPvAnyAddress, field_validator
|
|
|
|
|
|
|
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
2025-01-05 14:41:07 +01:00
|
|
|
from akkudoktoreos.core.logging import get_logger
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
2025-02-12 21:35:51 +01:00
|
|
|
def get_default_host() -> str:
|
2025-06-03 08:30:37 +02:00
|
|
|
"""Default host for EOS."""
|
|
|
|
return "127.0.0.1"
|
|
|
|
|
|
|
|
|
|
|
|
def is_valid_ip_or_hostname(value: str) -> bool:
|
|
|
|
"""Validate whether a string is a valid IP address (IPv4 or IPv6) or hostname.
|
|
|
|
|
|
|
|
This function first attempts to interpret the input as an IP address using the
|
|
|
|
standard library `ipaddress` module. If that fails, it checks whether the input
|
|
|
|
is a valid hostname according to RFC 1123, which allows domain names consisting
|
|
|
|
of alphanumeric characters and hyphens, with specific length and structure rules.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
value (str): The input string to validate.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
bool: True if the input is a valid IP address or hostname, False otherwise.
|
|
|
|
"""
|
|
|
|
try:
|
|
|
|
ipaddress.ip_address(value)
|
|
|
|
return True
|
|
|
|
except ValueError:
|
|
|
|
pass
|
|
|
|
|
|
|
|
if len(value) > 253:
|
|
|
|
return False
|
|
|
|
|
|
|
|
hostname_regex = re.compile(
|
|
|
|
r"^(?=.{1,253}$)(?!-)[A-Z\d-]{1,63}(?<!-)"
|
|
|
|
r"(?:\.(?!-)[A-Z\d-]{1,63}(?<!-))*\.?$",
|
|
|
|
re.IGNORECASE,
|
|
|
|
)
|
|
|
|
return bool(hostname_regex.fullmatch(value))
|
2025-02-12 21:35:51 +01:00
|
|
|
|
|
|
|
|
2025-01-22 23:47:28 +01:00
|
|
|
def wait_for_port_free(port: int, timeout: int = 0, waiting_app_name: str = "App") -> bool:
|
|
|
|
"""Wait for a network port to become free, with timeout.
|
|
|
|
|
|
|
|
Checks if the port is currently in use and logs warnings with process details.
|
|
|
|
Retries every 3 seconds until timeout is reached.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
port: The network port number to check
|
|
|
|
timeout: Maximum seconds to wait (0 means check once without waiting)
|
|
|
|
waiting_app_name: Name of the application waiting for the port
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-01-22 23:47:28 +01:00
|
|
|
Returns:
|
|
|
|
bool: True if port is free, False if port is still in use after timeout
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
ValueError: If port number or timeout is invalid
|
|
|
|
psutil.Error: If there are problems accessing process information
|
2024-12-15 14:40:03 +01:00
|
|
|
"""
|
2025-01-22 23:47:28 +01:00
|
|
|
if not 0 <= port <= 65535:
|
|
|
|
raise ValueError(f"Invalid port number: {port}")
|
|
|
|
if timeout < 0:
|
|
|
|
raise ValueError(f"Invalid timeout: {timeout}")
|
|
|
|
|
|
|
|
def get_processes_using_port() -> list[dict]:
|
|
|
|
"""Get info about processes using the specified port."""
|
|
|
|
processes: list[dict] = []
|
|
|
|
seen_pids: set[int] = set()
|
|
|
|
|
|
|
|
try:
|
|
|
|
for conn in psutil.net_connections(kind="inet"):
|
|
|
|
if conn.laddr.port == port and conn.pid not in seen_pids:
|
|
|
|
try:
|
|
|
|
process = psutil.Process(conn.pid)
|
|
|
|
seen_pids.add(conn.pid)
|
|
|
|
processes.append(process.as_dict(attrs=["pid", "cmdline"]))
|
|
|
|
except psutil.NoSuchProcess:
|
|
|
|
continue
|
|
|
|
except psutil.Error as e:
|
|
|
|
logger.error(f"Error checking port {port}: {e}")
|
|
|
|
raise
|
|
|
|
|
|
|
|
return processes
|
|
|
|
|
|
|
|
retries = max(int(timeout / 3), 1) if timeout > 0 else 1
|
|
|
|
|
|
|
|
for _ in range(retries):
|
|
|
|
process_info = get_processes_using_port()
|
|
|
|
|
|
|
|
if not process_info:
|
|
|
|
return True
|
|
|
|
|
|
|
|
if timeout <= 0:
|
|
|
|
break
|
|
|
|
|
|
|
|
logger.info(f"{waiting_app_name} waiting for port {port} to become free...")
|
|
|
|
time.sleep(3)
|
|
|
|
|
|
|
|
if process_info:
|
|
|
|
logger.warning(
|
|
|
|
f"{waiting_app_name} port {port} still in use after waiting {timeout} seconds."
|
|
|
|
)
|
|
|
|
for info in process_info:
|
|
|
|
logger.warning(
|
|
|
|
f"Process using port - PID: {info['pid']}, Command: {' '.join(info['cmdline'])}"
|
|
|
|
)
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
class ServerCommonSettings(SettingsBaseModel):
|
|
|
|
"""Server Configuration."""
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-02-12 21:35:51 +01:00
|
|
|
host: Optional[IPvAnyAddress] = Field(
|
|
|
|
default=get_default_host(), description="EOS server IP address."
|
|
|
|
)
|
2025-01-18 14:26:34 +01:00
|
|
|
port: Optional[int] = Field(default=8503, description="EOS server IP port number.")
|
|
|
|
verbose: Optional[bool] = Field(default=False, description="Enable debug output")
|
|
|
|
startup_eosdash: Optional[bool] = Field(
|
2025-01-09 16:54:49 +01:00
|
|
|
default=True, description="EOS server to start EOSdash server."
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-18 14:26:34 +01:00
|
|
|
eosdash_host: Optional[IPvAnyAddress] = Field(
|
2025-02-12 21:35:51 +01:00
|
|
|
default=get_default_host(), description="EOSdash server IP address."
|
2024-12-29 18:42:49 +01:00
|
|
|
)
|
2025-01-18 14:26:34 +01:00
|
|
|
eosdash_port: Optional[int] = Field(default=8504, description="EOSdash server IP port number.")
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-01-22 23:47:28 +01:00
|
|
|
@field_validator("host", "eosdash_host", mode="before")
|
|
|
|
def validate_server_host(
|
|
|
|
cls, value: Optional[Union[str, IPvAnyAddress]]
|
|
|
|
) -> Optional[Union[str, IPvAnyAddress]]:
|
|
|
|
if isinstance(value, str):
|
2025-06-03 08:30:37 +02:00
|
|
|
if not is_valid_ip_or_hostname(value):
|
|
|
|
raise ValueError(f"Invalid host: {value}")
|
2025-01-22 23:47:28 +01:00
|
|
|
if value.lower() in ("localhost", "loopback"):
|
|
|
|
value = "127.0.0.1"
|
|
|
|
return value
|
|
|
|
|
2025-01-18 14:26:34 +01:00
|
|
|
@field_validator("port", "eosdash_port")
|
2024-12-15 14:40:03 +01:00
|
|
|
def validate_server_port(cls, value: Optional[int]) -> Optional[int]:
|
|
|
|
if value is not None and not (1024 <= value <= 49151):
|
|
|
|
raise ValueError("Server port number must be between 1024 and 49151.")
|
|
|
|
return value
|