1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-10-23 23:34:07 +03:00
Files
openuds/server/src/uds/REST/methods/servers_management.py

563 lines
22 KiB
Python

# -*- coding: utf-8 -*-
#
# Copyright (c) 2023 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
from django.utils.translation import gettext, gettext_lazy as _
from django.db.models import Model
from uds import models
from uds.core import consts, exceptions, types
from uds.core.types.rest import TableInfo
from uds.core.util import net, permissions, ensure, ui as ui_utils
from uds.core.util.model import sql_now, process_uuid
from uds.core.exceptions.rest import NotFound, RequestError
from uds.REST.model import DetailHandler, ModelHandler
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class TokenItem(types.rest.BaseRestItem):
id: str
name: str
stamp: datetime.datetime
username: str
ip: str
hostname: str
listen_port: int
mac: str
token: str
type: str
os: str
# REST API for Server Tokens management (for admin interface)
class ServersTokens(ModelHandler[TokenItem]):
# servers/groups/[id]/servers
MODEL = models.Server
EXCLUDE = {
'type__in': [
types.servers.ServerType.ACTOR,
types.servers.ServerType.UNMANAGED,
]
}
PATH = 'servers'
NAME = 'tokens'
TABLE = (
ui_utils.TableBuilder(_('Registered Servers'))
.text_column(name='hostname', title=_('Hostname'), visible=True)
.text_column(name='ip', title=_('IP'), visible=True)
.text_column(name='mac', title=_('MAC'), visible=True)
.text_column(name='type', title=_('Type'), visible=False)
.text_column(name='os', title=_('OS'), visible=True)
.text_column(name='username', title=_('Issued by'), visible=True)
.datetime_column(name='stamp', title=_('Date'), visible=True)
.text_column(name='mac', title=_('MAC Address'), visible=False)
.build()
)
def get_item(self, item: 'Model') -> TokenItem:
item = typing.cast('models.Server', item) # We will receive for sure
return TokenItem(
id=item.uuid,
name=str(_('Token isued by {} from {}')).format(item.register_username, item.ip),
stamp=item.stamp,
username=item.register_username,
ip=item.ip,
hostname=item.hostname,
listen_port=item.listen_port,
mac=item.mac,
token=item.token,
type=types.servers.ServerType(item.type).as_str(),
os=item.os_type,
)
def delete(self) -> str:
"""
Processes a DELETE request
"""
if len(self._args) != 1:
raise RequestError('Delete need one and only one argument')
self.check_access(
self.MODEL(), types.permissions.PermissionType.ALL, root=True
) # Must have write permissions to delete
try:
self.MODEL.objects.get(uuid=process_uuid(self._args[0])).delete()
except self.MODEL.DoesNotExist:
raise NotFound('Element do not exists') from None
return consts.OK
@dataclasses.dataclass
class ServerItem(types.rest.BaseRestItem):
id: str
hostname: str
ip: str
listen_port: int
mac: str
maintenance_mode: bool
register_username: str
stamp: datetime.datetime
# REST API For servers (except tunnel servers nor actors)
class ServersServers(DetailHandler[ServerItem]):
CUSTOM_METHODS = ['maintenance', 'importcsv']
# Rest api related information to complete the auto-generated API
REST_API_INFO = types.rest.api.RestApiInfo(
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
)
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[ServerItem]:
parent = typing.cast('models.ServerGroup', parent) # We will receive for sure
try:
if item is None:
q = self.filter_queryset(parent.servers.all())
else:
q = parent.servers.filter(uuid=process_uuid(item))
res: list[ServerItem] = []
i = None
for i in q:
res.append(
ServerItem(
id=i.uuid,
hostname=i.hostname,
ip=i.ip,
listen_port=i.listen_port,
mac=i.mac if i.mac != consts.NULL_MAC else '',
maintenance_mode=i.maintenance_mode,
register_username=i.register_username,
stamp=i.stamp,
)
)
if item is None:
return res
if not i:
raise exceptions.rest.NotFound(f'Server not found: {item}')
return res[0]
except exceptions.rest.HandlerError:
raise
except Exception:
logger.exception('Error getting server')
raise exceptions.rest.ResponseError(_('Error getting server')) from None
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, models.ServerGroup)
table_info = (
ui_utils.TableBuilder(_('Servers of {0}').format(parent.name))
.text_column(name='hostname', title=_('Hostname'))
.text_column(name='ip', title=_('Ip'))
.text_column(name='mac', title=_('Mac'))
)
if parent.is_managed():
table_info.text_column(name='listen_port', title=_('Port'))
return (
table_info.dict_column(
name='maintenance_mode',
title=_('State'),
dct={True: _('Maintenance'), False: _('Normal')},
)
.row_style(prefix='row-maintenance-', field='maintenance_mode')
.build()
)
def get_gui(self, parent: 'Model', for_type: str) -> list[types.ui.GuiElement]:
parent = ensure.is_instance(parent, models.ServerGroup)
kind, subkind = parent.server_type, parent.subtype
title = _('of type') + f' {subkind.upper()} {kind.name.capitalize()}'
gui_builder = ui_utils.GuiBuilder(order=100)
if kind == types.servers.ServerType.UNMANAGED:
return (
gui_builder.add_text(
name='hostname',
label=gettext('Hostname'),
tooltip=gettext('Hostname of the server. It must be resolvable by UDS'),
default='',
)
.add_text(
name='ip',
label=gettext('IP'),
)
.add_text(
name='mac',
label=gettext('Server MAC'),
tooltip=gettext('Optional MAC address of the server'),
default='',
)
.add_info(
name='title',
default=title,
)
.build()
)
return (
gui_builder.add_text(
name='server',
label=gettext('Server'),
tooltip=gettext('Server to include on group'),
default='',
)
.add_info(name='title', default=title)
.build()
)
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServerGroup)
# Item is the uuid of the server to add
server: typing.Optional['models.Server'] = None # Avoid warning on reference before assignment
mac: str = ''
if item is None:
# Create new, depending on server type
if parent.type == types.servers.ServerType.UNMANAGED:
# Ensure mac is empty or valid
mac = self._params['mac'].strip().upper()
if mac and not net.is_valid_mac(mac):
raise exceptions.rest.RequestError(_('Invalid MAC address'))
# Create a new one, and add it to group
server = models.Server.objects.create(
register_username=self._user.pretty_name,
register_ip=self._request.ip,
ip=self._params['ip'],
hostname=self._params['hostname'],
listen_port=0,
mac=mac,
type=parent.type,
subtype=parent.subtype,
stamp=sql_now(),
)
# Add to group
parent.servers.add(server)
return {'id': server.uuid}
elif parent.type == types.servers.ServerType.SERVER:
# Get server
try:
server = models.Server.objects.get(uuid=process_uuid(self._params['server']))
# Check server type is also SERVER
if server and server.type != types.servers.ServerType.SERVER:
logger.error('Server type for %s is not SERVER', server.host)
raise exceptions.rest.RequestError('Invalid server type') from None
parent.servers.add(server)
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(f'Server not found: {self._params["server"]}') from None
except Exception as e:
logger.error('Error getting server: %s', e)
raise exceptions.rest.ResponseError('Error getting server') from None
return {'id': server.uuid}
else:
if parent.type == types.servers.ServerType.UNMANAGED:
mac = self._params['mac'].strip().upper()
if mac and not net.is_valid_mac(mac):
raise exceptions.rest.RequestError('Invalid MAC address')
try:
models.Server.objects.filter(uuid=process_uuid(item)).update(
# Update register info also on update
register_username=self._user.pretty_name,
register_ip=self._request.ip,
hostname=self._params['hostname'],
ip=self._params['ip'],
mac=mac,
stamp=sql_now(), # Modified now
)
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(f'Server not found: {item}') from None
except Exception as e:
logger.error('Error updating server: %s', e)
raise exceptions.rest.ResponseError('Error updating server') from None
else:
# Remove current server and add the new one in a single transaction
try:
server = models.Server.objects.get(uuid=process_uuid(item))
parent.servers.add(server)
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(f'Server not found: {item}') from None
return {'id': item}
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, models.ServerGroup)
try:
server = models.Server.objects.get(uuid=process_uuid(item))
if parent.server_type == types.servers.ServerType.UNMANAGED:
parent.servers.remove(server) # Remove reference
server.delete() # and delete server
else:
parent.servers.remove(server) # Just remove reference
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(f'Server not found: {item}') from None
except Exception as e:
logger.error('Error deleting server %s from %s: %s', item, parent, e)
raise exceptions.rest.ResponseError('Error deleting server') from None
# Custom methods
def maintenance(self, parent: 'Model', id: str) -> typing.Any:
parent = ensure.is_instance(parent, models.ServerGroup)
"""
Custom method that swaps maintenance mode state for a server
:param item:
"""
item = models.Server.objects.get(uuid=process_uuid(id))
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
item.maintenance_mode = not item.maintenance_mode
item.save()
return 'ok'
def importcsv(self, parent: 'Model') -> typing.Any:
"""
We receive a json with string[][] format with the data.
Has no header, only the data.
"""
parent = ensure.is_instance(parent, models.ServerGroup)
data: list[list[str]] = self._params.get('data', [])
logger.debug('Data received: %s', data)
# String lines can have 1, 2 or 3 fields.
# if 1, it's a IP
# if 2, it's a IP and a hostname. Hostame can be empty, in this case, it will be the same as IP
# if 3, it's a IP, a hostname and a MAC. MAC can be empty, in this case, it will be UNKNOWN
# if ip is empty and has a hostname, it will be kept, but if it has no hostname, it will be skipped
# If the IP is invalid and has no hostname, it will be skipped
import_errors: list[str] = []
for line_number, row in enumerate(data, 1):
if len(row) == 0:
continue
hostname = row[0].strip()
ip = ''
mac = consts.NULL_MAC
if len(row) > 1:
ip = row[1].strip()
if len(row) > 2:
mac = row[2].strip().upper().strip() or consts.NULL_MAC
if mac and not net.is_valid_mac(mac):
import_errors.append(f'Line {line_number}: MAC {mac} is invalid, skipping')
continue # skip invalid macs
if ip and not net.is_valid_ip(ip):
import_errors.append(f'Line {line_number}: IP {ip} is invalid, skipping')
continue # skip invalid ips if not empty
# Must have at least a valid ip or a valid hostname
if not ip and not hostname:
import_errors.append(f'Line {line_number}: No IP or hostname, skipping')
continue
if hostname and not net.is_valid_host(hostname):
# Log it has been skipped
import_errors.append(f'Line {line_number}: Hostname {hostname} is invalid, skipping')
continue # skip invalid hostnames
# Seems valid, create server if not exists already (by ip OR hostname)
logger.debug('Creating server with ip %s, hostname %s and mac %s', ip, hostname, mac)
try:
q = parent.servers.all()
if ip != '':
q = q.filter(ip=ip)
if hostname != '':
q = q.filter(hostname=hostname)
if q.count() == 0:
server = models.Server.objects.create(
register_username=self._user.pretty_name,
register_ip=self._request.ip,
ip=ip,
hostname=hostname,
listen_port=0,
mac=mac,
type=parent.type,
subtype=parent.subtype,
stamp=sql_now(),
)
parent.servers.add(server) # And register it on group
else:
# Log it has been skipped
import_errors.append(f'Line {line_number}: duplicated server, skipping')
except Exception as e:
import_errors.append(f'Error creating server on line {line_number}: {str(e)}')
logger.exception('Error creating server on line %s', line_number)
return import_errors
@dataclasses.dataclass
class GroupItem(types.rest.BaseRestItem):
id: str
name: str
comments: str
type: str
subtype: str
type_name: str
tags: list[str]
servers_count: int
permission: types.permissions.PermissionType
class ServersGroups(ModelHandler[GroupItem]):
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('stats', True),
]
MODEL = models.ServerGroup
FILTER = {
'type__in': [
types.servers.ServerType.SERVER,
types.servers.ServerType.UNMANAGED,
]
}
DETAIL = {'servers': ServersServers}
PATH = 'servers'
NAME = 'groups'
FIELDS_TO_SAVE = ['name', 'comments', 'type', 'tags'] # Subtype is appended on pre_save
TABLE = (
ui_utils.TableBuilder(_('Servers Groups'))
.text_column(name='name', title=_('Name'), visible=True)
.text_column(name='comments', title=_('Comments'))
.text_column(name='type_name', title=_('Type'), visible=True)
.text_column(name='type', title='', visible=False)
.text_column(name='subtype', title=_('Subtype'), visible=True)
.numeric_column(name='servers_count', title=_('Servers'), width='5rem')
.text_column(name='tags', title=_('tags'), visible=False)
.build()
)
# Rest api related information to complete the auto-generated API
REST_API_INFO = types.rest.api.RestApiInfo(
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
)
def enum_types(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.TypeInfo, None, None]:
for i in types.servers.ServerSubtype.manager().enum():
yield types.rest.TypeInfo(
name=i.description,
type=f'{i.type.name}@{i.subtype}',
description='',
icon=i.icon,
group=gettext('Managed') if i.managed else gettext('Unmanaged'),
)
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
if '@' not in for_type: # If no subtype, use default
for_type += '@default'
kind, subkind = for_type.split('@')[:2]
if kind == types.servers.ServerType.SERVER.name:
kind = _('Standard')
elif kind == types.servers.ServerType.UNMANAGED.name:
kind = _('Unmanaged')
title = _('of type') + f' {subkind.upper()} {kind}'
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_hidden(name='type', default=for_type)
.add_info(
name='title',
default=title,
)
.build()
)
def pre_save(self, fields: dict[str, typing.Any]) -> None:
# Update type and subtype to correct values
type, subtype = fields['type'].split('@')
fields['type'] = types.servers.ServerType[type.upper()].value
fields['subtype'] = subtype
return super().pre_save(fields)
def get_item(self, item: 'Model') -> GroupItem:
item = ensure.is_instance(item, models.ServerGroup)
return GroupItem(
id=item.uuid,
name=item.name,
comments=item.comments,
type=f'{types.servers.ServerType(item.type).name}@{item.subtype}',
subtype=item.subtype.capitalize(),
type_name=types.servers.ServerType(item.type).name.capitalize(),
tags=[tag.tag for tag in item.tags.all()],
servers_count=item.servers.count(),
permission=permissions.effective_permissions(self._user, item),
)
def delete_item(self, item: 'Model') -> None:
item = ensure.is_instance(item, models.ServerGroup)
"""
Processes a DELETE request
"""
self.check_access(
self.MODEL(), permissions.PermissionType.ALL, root=True
) # Must have write permissions to delete
try:
if item.type == types.servers.ServerType.UNMANAGED:
# Unmanaged has to remove ALSO the servers
for server in item.servers.all():
server.delete()
item.delete()
except self.MODEL.DoesNotExist:
raise NotFound('Element do not exists') from None
def stats(self, item: 'Model') -> typing.Any:
# Avoid circular imports
from uds.core.managers.servers import ServerManager
item = ensure.is_instance(item, models.ServerGroup)
return [
{
'stats': s[0].as_dict() if s[0] else None,
'server': {
'id': s[1].uuid,
'hostname': s[1].hostname,
'mac': s[1].mac if s[1].mac != consts.NULL_MAC else '',
'ip': s[1].ip,
'load': s[0].load(weights=item.weights) if s[0] else 0,
'weights': item.weights.as_dict(),
},
}
for s in ServerManager.manager().get_server_stats(item.servers.all())
]