mirror of
https://github.com/dkmstr/openuds.git
synced 2025-11-22 00:24:29 +03:00
Compare commits
12 Commits
dev/andres
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3e24f16f6 | ||
|
|
3952c57b69 | ||
|
|
88a99c7220 | ||
|
|
3e56748260 | ||
|
|
31813f4f97 | ||
|
|
6c6e2f6417 | ||
|
|
584ebe2e74 | ||
|
|
7b28963dce | ||
|
|
d19086e7cc | ||
|
|
67a58d57cb | ||
|
|
39a046bb23 | ||
|
|
ae16e78a4a |
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -1,11 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip" # See documentation for possible values
|
||||
directory: "/server" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
19
.github/workflows/auto-assign.yml
vendored
19
.github/workflows/auto-assign.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: Auto Assign
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 'Auto-assign issue'
|
||||
uses: pozil/auto-assign-issue@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
assignees: dkmstr
|
||||
numOfAssignee: 1
|
||||
20
.github/workflows/dependency-review.yml
vendored
20
.github/workflows/dependency-review.yml
vendored
@@ -1,20 +0,0 @@
|
||||
# Dependency Review Action
|
||||
#
|
||||
# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
|
||||
#
|
||||
# Source repository: https://github.com/actions/dependency-review-action
|
||||
# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
|
||||
name: 'Dependency Review'
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v3
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v3
|
||||
69
.github/workflows/test.yml
vendored
69
.github/workflows/test.yml
vendored
@@ -1,69 +0,0 @@
|
||||
name: Test OpenUDS
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libsasl2-dev \
|
||||
libxmlsec1-dev \
|
||||
python3-dev \
|
||||
libldap2-dev \
|
||||
libssl-dev \
|
||||
libmemcached-dev \
|
||||
zlib1g-dev \
|
||||
gcc
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Install lxmlsec with local libraries to avoid binary wheel issues
|
||||
pip install --upgrade --no-binary lxml --no-binary xmlsec lxml xmlsec
|
||||
# Install other requirements
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Set PYTHONPATH
|
||||
run: echo "PYTHONPATH=$PWD/src" >> $GITHUB_ENV
|
||||
|
||||
- name: Copy Django settings
|
||||
run: cp src/server/settings.py.sample src/server/settings.py
|
||||
|
||||
- name: Generate RSA key and set as environment variable
|
||||
run: |
|
||||
openssl genrsa 2048 > private.pem
|
||||
RSA_KEY=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)
|
||||
echo "RSA_KEY=$RSA_KEY" >> $GITHUB_ENV
|
||||
|
||||
- name: Patch settings.py with generated RSA key
|
||||
run: |
|
||||
sed -i "s|^RSA_KEY = .*|RSA_KEY = '''$RSA_KEY'''|" src/server/settings.py
|
||||
|
||||
- name: Create log directory
|
||||
run: mkdir -p src/log
|
||||
|
||||
- name: Run tests with pytest
|
||||
run: python3 -m pytest
|
||||
88
.gitignore
vendored
88
.gitignore
vendored
@@ -1,87 +1 @@
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.orig
|
||||
*~
|
||||
*.swp
|
||||
.DS_Store
|
||||
*_enterprise.*
|
||||
.settings/
|
||||
.ipynb_checkpoints
|
||||
.idea/
|
||||
|
||||
# Debian buildings
|
||||
*.debhelper*
|
||||
*-stamp
|
||||
*.substvars
|
||||
.coverage*
|
||||
|
||||
# /server/
|
||||
*_enterprise
|
||||
|
||||
# /server/src/
|
||||
/server/src/taskmanager.pid
|
||||
/server/src/testing
|
||||
/server/src/*_enterprise*
|
||||
|
||||
# /server/src/log/
|
||||
/server/src/log/uds.log.*
|
||||
/server/src/log/sql.log.*
|
||||
/server/src/log/*.log.*
|
||||
|
||||
# /server/src/server/
|
||||
/server/src/server/settings.py
|
||||
/server/src/server/*_enterprise.py
|
||||
/server/src/server/enterprise*
|
||||
|
||||
# /server/src/static/
|
||||
/server/src/static/cache
|
||||
|
||||
# /server/src/uds/
|
||||
/server/src/uds/*_enterprise.py
|
||||
/server/src/uds/fixtures
|
||||
|
||||
# /server/src/uds/auths/
|
||||
/server/src/uds/auths/*-enterprise
|
||||
/server/src/uds/auths/*_enterprise
|
||||
|
||||
# /server/src/uds/core/util/
|
||||
/server/src/uds/core/util/*enterprise.py
|
||||
|
||||
# /server/src/uds/dispatchers/
|
||||
/server/src/uds/dispatchers/*_enterprise
|
||||
|
||||
# /server/src/uds/locale/
|
||||
/server/src/uds/locale/*.sh
|
||||
|
||||
# /server/src/uds/management/commands/
|
||||
/server/src/uds/management/commands/*_enterprise.py
|
||||
|
||||
# /server/src/uds/models/
|
||||
/server/src/uds/models/enterprise*
|
||||
|
||||
# /server/src/uds/plugins/
|
||||
/server/src/uds/plugins/enterprise*
|
||||
|
||||
# /server/src/uds/services/
|
||||
/server/src/uds/services/*-enterprise
|
||||
/server/src/uds/services/*_enterprise
|
||||
|
||||
# /server/src/uds/static/adm/css/
|
||||
/server/src/uds/static/adm/css/admin.min.css
|
||||
|
||||
# /server/src/uds/static/adm/js/
|
||||
/server/src/uds/static/adm/js/admin.min.js
|
||||
|
||||
# /server/src/uds/templates/uds/admin/
|
||||
/server/src/uds/templates/uds/admin/sample.html
|
||||
|
||||
# /server/src/uds/transports/
|
||||
/server/src/uds/transports/*-enterprise
|
||||
|
||||
.vscode
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.python-version
|
||||
|
||||
target/
|
||||
server/test-vars.ini
|
||||
*_enterprise*
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -14,3 +14,7 @@
|
||||
path = client
|
||||
url = git@github.com:VirtualCable/uds-client.git
|
||||
branch = master
|
||||
[submodule "server"]
|
||||
path = server
|
||||
url = git@github.com:VirtualCable/uds-broker.git
|
||||
branch = master
|
||||
|
||||
2
actor
2
actor
Submodule actor updated: 10b407ced9...8af8b39db3
@@ -1,18 +0,0 @@
|
||||
===========================
|
||||
Admin Configuration Guide
|
||||
===========================
|
||||
|
||||
Overview
|
||||
========
|
||||
This document provides essential information for administrators configuring **OpenUDS**.
|
||||
It covers the initial setup, default credentials, and key configuration steps.
|
||||
|
||||
Default Credentials
|
||||
===================
|
||||
When OpenUDS is first installed, it comes with a default administrator account:
|
||||
|
||||
- **Username:** ``root``
|
||||
- **Password:** ``udsmam0``
|
||||
|
||||
.. warning::
|
||||
For security reasons, you should change this password immediately after first login.
|
||||
@@ -1,6 +0,0 @@
|
||||
====================
|
||||
Quickstart Guide
|
||||
====================
|
||||
|
||||
This guide walks you through the essential steps to get OpenUDS running quickly.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
===================
|
||||
Release Notes
|
||||
===================
|
||||
|
||||
Version 4.0.0 (Initial)
|
||||
=======================
|
||||
- Initial release of OpenUDS documentation.
|
||||
- Added guides for:
|
||||
- Admin configuration
|
||||
- Quickstart setup
|
||||
- Default user credentials
|
||||
|
||||
Upcoming Features
|
||||
=================
|
||||
-
|
||||
@@ -1,14 +0,0 @@
|
||||
===================
|
||||
User Guide
|
||||
===================
|
||||
|
||||
This document describes how end-users interact with OpenUDS.
|
||||
|
||||
Logging In
|
||||
==========
|
||||
1. Visit the OpenUDS portal at ``http://<server-ip>:8000``.
|
||||
2. Enter your assigned username and password.
|
||||
3. If you’re using the default admin account, the credentials are:
|
||||
|
||||
- **Username:** ``root``
|
||||
- **Password:** ``udsmam0``
|
||||
1
server
Submodule
1
server
Submodule
Submodule server added at 6ac76a8817
@@ -1,2 +0,0 @@
|
||||
PYTHONPATH=./src:${PYTHONPATH}
|
||||
|
||||
637
server/.pylintrc
637
server/.pylintrc
@@ -1,637 +0,0 @@
|
||||
[MAIN]
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
|
||||
# in a server-like mode.
|
||||
clear-cache-post-run=no
|
||||
|
||||
# Load and enable all available extensions. Use --list-extensions to see a list
|
||||
# all available extensions.
|
||||
enable-all-extensions=yes
|
||||
|
||||
# In error mode, messages with a category besides ERROR or FATAL are
|
||||
# suppressed, and no reports are done by default. Error mode is compatible with
|
||||
# disabling specific errors.
|
||||
#errors-only=
|
||||
|
||||
# Always return a 0 (non-error) status code, even if lint errors are found.
|
||||
# This is primarily useful in continuous integration scripts.
|
||||
#exit-zero=
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-allow-list=lxml.etree
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
|
||||
# for backward compatibility.)
|
||||
extension-pkg-whitelist=
|
||||
|
||||
# Return non-zero exit code if any of these messages/categories are detected,
|
||||
# even if score is above --fail-under value. Syntax same as enable. Messages
|
||||
# specified are enabled, while categories only check already-enabled messages.
|
||||
fail-on=
|
||||
|
||||
# Specify a score threshold under which the program will exit with error.
|
||||
fail-under=10
|
||||
|
||||
# Interpret the stdin as a python script, whose filename needs to be passed as
|
||||
# the module_or_package argument.
|
||||
#from-stdin=
|
||||
|
||||
# Files or directories to be skipped. They should be base names, not paths.
|
||||
ignore=CVS
|
||||
|
||||
# Add files or directories matching the regular expressions patterns to the
|
||||
# ignore-list. The regex matches against paths and can be in Posix or Windows
|
||||
# format. Because '\\' represents the directory delimiter on Windows systems,
|
||||
# it can't be used as an escape character.
|
||||
ignore-paths=
|
||||
|
||||
# Files or directories matching the regular expression patterns are skipped.
|
||||
# The regex matches against base names, not paths. The default value ignores
|
||||
# Emacs file locks
|
||||
ignore-patterns=^\.#
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis). It
|
||||
# supports qualified module names, as well as Unix pattern matching.
|
||||
ignored-modules=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
init-hook='import sys, os; sys.path.append(os.path.join(os.getcwd(), "src"))'
|
||||
|
||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
||||
# number of processors available to use, and will cap the count on Windows to
|
||||
# avoid hangs.
|
||||
jobs=4
|
||||
|
||||
# Control the amount of potential inferred values when inferring a single
|
||||
# object. This can help the performance when dealing with large functions or
|
||||
# complex, nested conditions.
|
||||
limit-inference-results=100
|
||||
|
||||
# List of plugins (as comma separated values of python module names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.11
|
||||
|
||||
# Discover python modules and packages in the file system subtree.
|
||||
recursive=no
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
# In verbose mode, extra non-checker-related info will be displayed.
|
||||
#verbose=
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming style matching correct argument names.
|
||||
argument-naming-style=camelCase
|
||||
|
||||
# Regular expression matching correct argument names. Overrides argument-
|
||||
# naming-style. If left empty, argument names will be checked with the set
|
||||
# naming style.
|
||||
#argument-rgx=
|
||||
|
||||
# Naming style matching correct attribute names.
|
||||
attr-naming-style=camelCase
|
||||
|
||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||
# style. If left empty, attribute names will be checked with the set naming
|
||||
# style.
|
||||
#attr-rgx=
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma.
|
||||
bad-names=foo,
|
||||
bar,
|
||||
baz,
|
||||
toto,
|
||||
tutu,
|
||||
tata
|
||||
|
||||
# Bad variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be refused
|
||||
bad-names-rgxs=
|
||||
|
||||
# Naming style matching correct class attribute names.
|
||||
class-attribute-naming-style=any
|
||||
|
||||
# Regular expression matching correct class attribute names. Overrides class-
|
||||
# attribute-naming-style. If left empty, class attribute names will be checked
|
||||
# with the set naming style.
|
||||
#class-attribute-rgx=
|
||||
|
||||
# Naming style matching correct class constant names.
|
||||
class-const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct class constant names. Overrides class-
|
||||
# const-naming-style. If left empty, class constant names will be checked with
|
||||
# the set naming style.
|
||||
#class-const-rgx=
|
||||
|
||||
# Naming style matching correct class names.
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# Regular expression matching correct class names. Overrides class-naming-
|
||||
# style. If left empty, class names will be checked with the set naming style.
|
||||
#class-rgx=
|
||||
|
||||
# Naming style matching correct constant names.
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct constant names. Overrides const-naming-
|
||||
# style. If left empty, constant names will be checked with the set naming
|
||||
# style.
|
||||
#const-rgx=
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming style matching correct function names.
|
||||
function-naming-style=camelCase
|
||||
|
||||
# Regular expression matching correct function names. Overrides function-
|
||||
# naming-style. If left empty, function names will be checked with the set
|
||||
# naming style.
|
||||
#function-rgx=
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma.
|
||||
good-names=i,
|
||||
j,
|
||||
k,
|
||||
ex,
|
||||
Run,
|
||||
_
|
||||
|
||||
# Good variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be accepted
|
||||
good-names-rgxs=
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name.
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming style matching correct inline iteration names.
|
||||
inlinevar-naming-style=any
|
||||
|
||||
# Regular expression matching correct inline iteration names. Overrides
|
||||
# inlinevar-naming-style. If left empty, inline iteration names will be checked
|
||||
# with the set naming style.
|
||||
#inlinevar-rgx=
|
||||
|
||||
# Naming style matching correct method names.
|
||||
method-naming-style=camelCase
|
||||
|
||||
# Regular expression matching correct method names. Overrides method-naming-
|
||||
# style. If left empty, method names will be checked with the set naming style.
|
||||
#method-rgx=
|
||||
|
||||
# Naming style matching correct module names.
|
||||
module-naming-style=camelCase
|
||||
|
||||
# Regular expression matching correct module names. Overrides module-naming-
|
||||
# style. If left empty, module names will be checked with the set naming style.
|
||||
#module-rgx=
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
# These decorators are taken in consideration only for invalid-name.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Regular expression matching correct type variable names. If left empty, type
|
||||
# variable names will be checked with the set naming style.
|
||||
#typevar-rgx=
|
||||
|
||||
# Naming style matching correct variable names.
|
||||
variable-naming-style=camelCase
|
||||
|
||||
# Regular expression matching correct variable names. Overrides variable-
|
||||
# naming-style. If left empty, variable names will be checked with the set
|
||||
# naming style.
|
||||
#variable-rgx=
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# Warn about protected attribute access inside special methods
|
||||
check-protected-access-in-special-methods=no
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,
|
||||
__new__,
|
||||
setUp,
|
||||
__post_init__
|
||||
|
||||
# List of member names, which should be excluded from the protected access
|
||||
# warning.
|
||||
exclude-protected=_asdict,
|
||||
_fields,
|
||||
_replace,
|
||||
_source,
|
||||
_make
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=mcs
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# List of regular expressions of class ancestor names to ignore when counting
|
||||
# public methods (see R0903)
|
||||
exclude-too-few-public-methods=
|
||||
|
||||
# List of qualified class names to ignore when counting class parents (see
|
||||
# R0901)
|
||||
ignored-parents=
|
||||
|
||||
# Maximum number of arguments for function / method.
|
||||
max-args=10
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=12
|
||||
|
||||
# Maximum number of boolean expressions in an if statement (see R0916).
|
||||
max-bool-expr=5
|
||||
|
||||
# Maximum number of branch for function / method body.
|
||||
max-branches=24
|
||||
|
||||
# Maximum number of locals for function / method body.
|
||||
max-locals=24
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=32
|
||||
|
||||
# Maximum number of return / yield for function / method body.
|
||||
max-returns=9
|
||||
|
||||
# Maximum number of statements in function / method body.
|
||||
max-statements=64
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=1
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when caught.
|
||||
overgeneral-exceptions=builtins.BaseException,builtins.Exception
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||
expected-line-ending-format=
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=100
|
||||
|
||||
# Maximum number of lines in a module.
|
||||
max-module-lines=1000
|
||||
|
||||
# Allow the body of a class to be on the same line as the declaration if body
|
||||
# contains single statement.
|
||||
single-line-class-stmt=no
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# List of modules that can be imported at any level, not just the top level
|
||||
# one.
|
||||
allow-any-import-level=
|
||||
|
||||
# Allow explicit reexports by alias from a package __init__.
|
||||
allow-reexport-from-package=no
|
||||
|
||||
# Allow wildcard imports from modules that define __all__.
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma.
|
||||
deprecated-modules=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of external dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
ext-import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of all (i.e. internal and
|
||||
# external) dependencies to the given file (report RP0402 must not be
|
||||
# disabled).
|
||||
import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of internal dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
int-import-graph=
|
||||
|
||||
# Force import order to recognize a module as part of the standard
|
||||
# compatibility libraries.
|
||||
known-standard-library=
|
||||
|
||||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
# Couples of modules and preferred modules, separated by a comma.
|
||||
preferred-modules=
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# The type of string formatting that logging methods do. `old` means using %
|
||||
# formatting, `new` is for `{}` formatting.
|
||||
logging-format-style=old
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format.
|
||||
logging-modules=logging
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
|
||||
# UNDEFINED.
|
||||
confidence=HIGH,
|
||||
CONTROL_FLOW,
|
||||
INFERENCE,
|
||||
INFERENCE_FAILURE,
|
||||
UNDEFINED
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once). You can also use "--disable=all" to
|
||||
# disable everything first and then re-enable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use "--disable=all --enable=classes
|
||||
# --disable=W".
|
||||
disable=raw-checker-failed,
|
||||
bad-inline-option,
|
||||
locally-disabled,
|
||||
file-ignored,
|
||||
suppressed-message,
|
||||
useless-suppression,
|
||||
deprecated-pragma,
|
||||
use-symbolic-message-instead,
|
||||
R0022,
|
||||
broad-exception-raised,
|
||||
invalid-name,
|
||||
broad-except,
|
||||
no-name-in-module, # Too many false positives... :(
|
||||
import-error,
|
||||
too-many-lines,
|
||||
redefined-builtin,
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once). See also the "--disable" option for examples.
|
||||
enable=c-extension-no-member
|
||||
|
||||
|
||||
[METHOD_ARGS]
|
||||
|
||||
# List of qualified names (i.e., library.method) which require a timeout
|
||||
# parameter e.g. 'requests.api.get,requests.api.post'
|
||||
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,
|
||||
XXX,
|
||||
TODO
|
||||
|
||||
# Regular expression of note tags to take in consideration.
|
||||
notes-rgx=
|
||||
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=8
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=sys.exit,argparse.parse_error
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Python expression which should return a score less than or equal to 10. You
|
||||
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
|
||||
# 'convention', and 'info' which contain the number of messages in each
|
||||
# category, as well as 'statement' which is the total number of statements
|
||||
# analyzed. This score is used by the global evaluation report (RP0004).
|
||||
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details.
|
||||
msg-template=
|
||||
|
||||
# Set the output format. Available formats are text, parseable, colorized, json
|
||||
# and msvs (visual studio). You can also give a reporter class, e.g.
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
#output-format=
|
||||
|
||||
# Tells whether to display a full report or only the messages.
|
||||
reports=no
|
||||
|
||||
# Activate the evaluation score.
|
||||
score=yes
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Comments are removed from the similarity computation
|
||||
ignore-comments=yes
|
||||
|
||||
# Docstrings are removed from the similarity computation
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Imports are removed from the similarity computation
|
||||
ignore-imports=yes
|
||||
|
||||
# Signatures are removed from the similarity computation
|
||||
ignore-signatures=yes
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
min-similarity-lines=4
|
||||
|
||||
|
||||
[SPELLING]
|
||||
|
||||
# Limits count of emitted suggestions for spelling mistakes.
|
||||
max-spelling-suggestions=4
|
||||
|
||||
# Spelling dictionary name. Available dictionaries: none. To make it work,
|
||||
# install the 'python-enchant' package.
|
||||
spelling-dict=
|
||||
|
||||
# List of comma separated words that should be considered directives if they
|
||||
# appear at the beginning of a comment and should not be checked.
|
||||
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
|
||||
|
||||
# List of comma separated words that should not be checked.
|
||||
spelling-ignore-words=
|
||||
|
||||
# A path to a file that contains the private dictionary; one word per line.
|
||||
spelling-private-dict-file=
|
||||
|
||||
# Tells whether to store unknown words to the private dictionary (see the
|
||||
# --spelling-private-dict-file option) instead of raising a message.
|
||||
spelling-store-unknown-words=no
|
||||
|
||||
|
||||
[STRING]
|
||||
|
||||
# This flag controls whether inconsistent-quotes generates a warning when the
|
||||
# character used as a quote delimiter is used inconsistently within a module.
|
||||
check-quote-consistency=no
|
||||
|
||||
# This flag controls whether the implicit-str-concat should generate a warning
|
||||
# on implicit string concatenation in sequences defined over several lines.
|
||||
check-str-concat-over-line-jumps=no
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# List of decorators that produce context managers, such as
|
||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
||||
# produce valid context managers.
|
||||
contextmanager-decorators=contextlib.contextmanager
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members= .*.objects
|
||||
.*.DoesNotExist.*
|
||||
.+service,
|
||||
.+osmanager,
|
||||
ldap\..+,
|
||||
|
||||
# Tells whether to warn about missing members when the owner of the attribute
|
||||
# is inferred to be None.
|
||||
ignore-none=yes
|
||||
|
||||
# This flag controls whether pylint should warn about no-member and similar
|
||||
# checks whenever an opaque object is returned when inferring. The inference
|
||||
# can return multiple potential results while evaluating a Python object, but
|
||||
# some branches might not be evaluated, which results in partial inference. In
|
||||
# that case, it might be useful to still emit no-member and other checks for
|
||||
# the rest of the inferred objects.
|
||||
ignore-on-opaque-inference=yes
|
||||
|
||||
# List of symbolic message names to ignore for Mixin members.
|
||||
ignored-checks-for-mixins=no-member,
|
||||
not-async-context-manager,
|
||||
not-context-manager,
|
||||
attribute-defined-outside-init
|
||||
|
||||
# List of class names for which member attributes should not be checked (useful
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
|
||||
|
||||
# Show a hint with possible names when a member name was not found. The aspect
|
||||
# of finding the hint is based on edit distance.
|
||||
missing-member-hint=yes
|
||||
|
||||
# The minimum edit distance a name should have in order to be considered a
|
||||
# similar match for a missing member name.
|
||||
missing-member-hint-distance=1
|
||||
|
||||
# The total number of similar names that should be taken in consideration when
|
||||
# showing a hint for a missing member.
|
||||
missing-member-max-choices=1
|
||||
|
||||
# Regex pattern to define which classes are considered mixins.
|
||||
mixin-class-rgx=.*[Mm]ixin
|
||||
|
||||
# List of decorators that change the signature of a decorated function.
|
||||
signature-mutators=
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid defining new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
# Tells whether unused global variables should be treated as a violation.
|
||||
allow-global-unused-variables=yes
|
||||
|
||||
# List of names allowed to shadow builtins
|
||||
allowed-redefined-builtins=
|
||||
|
||||
# List of strings which can identify a callback function by name. A callback
|
||||
# name must start or end with one of those strings.
|
||||
callbacks=cb_,
|
||||
_cb
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expected to
|
||||
# not be used).
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Argument names that match this expression will be ignored.
|
||||
ignored-argument-names=_.*|^ignored_|^unused_
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
|
||||
@@ -1,28 +0,0 @@
|
||||
import pytest
|
||||
import gc
|
||||
from django.db import connections
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def close_all_db_connections():
|
||||
yield
|
||||
for conn in connections.all():
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def pytest_sessionfinish(session: pytest.Session, exitstatus: pytest.ExitCode) -> None:
|
||||
"""Al final de toda la suite, cerrar conexiones y forzar GC."""
|
||||
try:
|
||||
from django.db import connections
|
||||
|
||||
for conn in connections.all():
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
gc.collect()
|
||||
@@ -1,17 +0,0 @@
|
||||
[run]
|
||||
dynamic_context = test_function
|
||||
omit =
|
||||
*/migrations/*
|
||||
*/tests/*
|
||||
branch = True
|
||||
|
||||
[report]
|
||||
skip_empty = True
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
raise NotImplementedError
|
||||
if typing.TYPE_CHECKING:
|
||||
|
||||
[html]
|
||||
show_contexts = True
|
||||
title = UDS Test Coverage Report
|
||||
22700
server/doc/api/rest.yaml
22700
server/doc/api/rest.yaml
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
[mypy]
|
||||
#plugins =
|
||||
# mypy_django_plugin.main
|
||||
python_version = 3.12
|
||||
|
||||
# Exclude all .*/transports/.*/scripts/.* directories and all tests
|
||||
exclude = (.*/transports/.*/scripts/.*|.*/tests/.*)
|
||||
|
||||
mypy_path = $MYPY_CONFIG_FILE_DIR/src
|
||||
# Call overload because ForeignKey fields are not being recognized with django-types
|
||||
disable_error_code = import, no-any-return, misc, redundant-cast, call-overload, no-untyped-call, no-untyped-call
|
||||
strict = True
|
||||
implicit_reexport = true
|
||||
|
||||
[mypy.plugins.django-stubs]
|
||||
django_settings_module = "server.settings"
|
||||
|
||||
# Disable some anoying reports, because pyright needs the redundant cast on some cases
|
||||
# [mypy-tests.*]
|
||||
# disable_error_code =
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"include": [
|
||||
"src/**/*.py",
|
||||
"tests/**/*.py",
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/scripts",
|
||||
"**/__pycache__",
|
||||
],
|
||||
"typeCheckingMode": "strict",
|
||||
"reportPrivateUsage": false,
|
||||
"reportUnusedImport": true,
|
||||
"reportMissingTypeStubs": false,
|
||||
"disableBytesTypePromotions": true,
|
||||
"stubPath": "../../enterprise/stubs",
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = server.settings
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
# Do not look for classes (all test clasess are descendant of unittest.TestCase), some of which are named Test* but are not tests (e.g. TestProvider)
|
||||
pythonpath = ./src
|
||||
python_classes =
|
||||
# If coverage is enabled, debug test will not work on vscode
|
||||
#addopts = --cov --cov-report html --cov-config=coverage.ini -n 12
|
||||
#addopts = --cov --cov-report html --cov-config=coverage.ini -n 12
|
||||
# addopts = -n 12
|
||||
filterwarnings =
|
||||
error
|
||||
ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated.:DeprecationWarning
|
||||
ignore::matplotlib._api.deprecation.MatplotlibDeprecationWarning:pydev
|
||||
ignore::pytest.PytestUnraisableExceptionWarning
|
||||
ignore::ResourceWarning:sqlite3
|
||||
|
||||
log_format = "%(asctime)s %(levelname)s %(message)s"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
log_cli = true
|
||||
log_level = debug
|
||||
@@ -1,58 +0,0 @@
|
||||
# Broker (and common)
|
||||
# Latest versions should work fine with master branch
|
||||
Django>5.2
|
||||
pytest
|
||||
pytest-django
|
||||
lark
|
||||
ldap3
|
||||
aiosmtpd
|
||||
pillow
|
||||
cairosvg
|
||||
bitarray
|
||||
numpy
|
||||
html5lib
|
||||
cryptography
|
||||
python3-saml
|
||||
six
|
||||
dnspython
|
||||
# lxml must be installed source to avoid conflicts
|
||||
ovirt-engine-sdk-python
|
||||
pycurl
|
||||
matplotlib
|
||||
mysqlclient
|
||||
python-ldap
|
||||
paramiko
|
||||
pyOpenSSL
|
||||
pyrad
|
||||
defusedxml
|
||||
python-dateutil
|
||||
requests
|
||||
WeasyPrint
|
||||
webencodings
|
||||
xml-marshaller
|
||||
ipython
|
||||
pyvmomi
|
||||
XenAPI
|
||||
PyJWT
|
||||
pylibmc
|
||||
gunicorn
|
||||
pywinrm
|
||||
pywinrm[credssp]
|
||||
whitenoise
|
||||
setproctitle
|
||||
openpyxl
|
||||
boto3
|
||||
uvicorn[standard]
|
||||
pandas
|
||||
xxhash
|
||||
psutil
|
||||
pyyaml
|
||||
pyotp
|
||||
qrcode
|
||||
qrcode[pil]
|
||||
art
|
||||
# For tunnel
|
||||
aiohttp
|
||||
uvloop
|
||||
argon2-cffi
|
||||
# psycopg2
|
||||
@@ -1,145 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014 Virtual Cable S.L.
|
||||
# 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
|
||||
'''
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from httplib2 import Http
|
||||
import json
|
||||
import sys
|
||||
|
||||
rest_url = 'http://172.27.0.1:8000/uds/rest/'
|
||||
|
||||
headers = {}
|
||||
|
||||
# Hace login con el root, puede usarse cualquier autenticador y cualquier usuario, pero en la 1.5 solo está implementado poder hacer
|
||||
# este tipo de login con el usuario "root"
|
||||
def login():
|
||||
global headers
|
||||
h = Http()
|
||||
|
||||
# parameters = '{ "auth": "admin", "username": "root", "password": "temporal" }'
|
||||
# parameters = '{ "auth": "interna", "username": "admin", "password": "temporal" }'
|
||||
parameters = '{ "auth": "interna", "username": "admin.1", "password": "temporal" }'
|
||||
|
||||
resp, content = h.request(rest_url + 'auth/login', method='POST', body=parameters)
|
||||
|
||||
if resp['status'] != '200': # Authentication error due to incorrect parameters, bad request, etc...
|
||||
print("Authentication error")
|
||||
return -1
|
||||
|
||||
# resp contiene las cabeceras, content el contenido de la respuesta (que es json), pero aún está en formato texto
|
||||
res = json.loads(content)
|
||||
print(res)
|
||||
if res['result'] != 'ok': # Authentication error
|
||||
print("Authentication error")
|
||||
return -1
|
||||
|
||||
headers['X-Auth-Token'] = res['token']
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def logout():
|
||||
global headers
|
||||
h = Http()
|
||||
|
||||
resp, content = h.request(rest_url + 'auth/logout', headers=headers)
|
||||
|
||||
if resp['status'] != '200': # Logout error due to incorrect parameters, bad request, etc...
|
||||
print("Error requesting logout")
|
||||
return -1
|
||||
|
||||
# Return value of logout method is nonsense (returns always done right now, but it's not important)
|
||||
|
||||
return 0
|
||||
|
||||
# Sample response from request_pools
|
||||
# [
|
||||
# {
|
||||
# u'initial_srvs': 0,
|
||||
# u'name': u'WinAdolfo',
|
||||
# u'max_srvs': 0,
|
||||
# u'comments': u'',
|
||||
# u'id': 6,
|
||||
# u'state': u'A',
|
||||
# u'user_services_count': 3,
|
||||
# u'cache_l2_srvs': 0,
|
||||
# u'service_id': 9,
|
||||
# u'provider_id': 2,
|
||||
# u'cache_l1_srvs': 0,
|
||||
# u'restrained': False}
|
||||
# ]
|
||||
|
||||
def request_pools():
|
||||
h = Http()
|
||||
|
||||
resp, content = h.request(rest_url + 'servicespools/overview', headers=headers)
|
||||
if resp['status'] != '200': # error due to incorrect parameters, bad request, etc...
|
||||
print("Error requesting pools")
|
||||
return {}
|
||||
|
||||
return json.loads(content)
|
||||
|
||||
# PATH: /rest/providers/[provider_id]/services/[service_id]
|
||||
def request_service_info(provider_id, service_id):
|
||||
h = Http()
|
||||
|
||||
resp, content = h.request(rest_url + 'providers/{0}/services/{1}'.format(provider_id, service_id), headers=headers)
|
||||
if resp['status'] != '200': # error due to incorrect parameters, bad request, etc...
|
||||
print("Error requesting pools: response: {}, content: {}".format(resp, content))
|
||||
return None
|
||||
|
||||
return json.loads(content)
|
||||
|
||||
if __name__ == '__main__':
|
||||
# request_pools() # Not logged in, this will generate an error
|
||||
|
||||
if login() == 0: # If we can log in, will get the pools correctly
|
||||
res = request_pools()
|
||||
print(res)
|
||||
sys.exit(0)
|
||||
for r in res:
|
||||
res2 = request_service_info(r['provider_id'], r['service_id'])
|
||||
if res2 is not None:
|
||||
print("Base Service info por pool {0}: {1}".format(r['name'], res2['type']))
|
||||
else:
|
||||
print("Base service {} is not accesible".format(r['name']))
|
||||
print("First logout")
|
||||
print(logout()) # This will success
|
||||
print("Second logout")
|
||||
print(logout()) # This will fail (already logged out)
|
||||
# Also new requests will fail
|
||||
print(request_pools())
|
||||
# Until we do log in again
|
||||
login()
|
||||
print(request_pools())
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014 Virtual Cable S.L.
|
||||
# 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 typing
|
||||
import collections.abc
|
||||
import requests
|
||||
|
||||
REST_URL: typing.Final[str] = 'http://172.27.0.1:8000/rest/'
|
||||
|
||||
# Global session
|
||||
session = requests.Session()
|
||||
|
||||
# Hace login con el root, puede usarse cualquier autenticador y cualquier usuario, pero en la 1.5 solo está implementado poder hacer
|
||||
# este tipo de login con el usuario "root"
|
||||
def login():
|
||||
|
||||
# parameters = '{ "auth": "admin", "username": "root", "password": "temporal" }'
|
||||
parameters = { "auth": "casa", "username": "172.27.0.1", "password": "" }
|
||||
|
||||
response = session.post(REST_URL + 'auth/login', parameters)
|
||||
|
||||
if response.status_code // 100 != 2: # Authentication error due to incorrect parameters, bad request, etc...
|
||||
print("Authentication error")
|
||||
return -1
|
||||
|
||||
# resp contiene las cabeceras, content el contenido de la respuesta (que es json), pero aún está en formato texto
|
||||
res = response.json()
|
||||
print(res)
|
||||
if res['result'] != 'ok': # Authentication error
|
||||
print("Authentication error")
|
||||
return -1
|
||||
|
||||
session.headers['X-Auth-Token'] = res['token']
|
||||
|
||||
return 0
|
||||
|
||||
def logout():
|
||||
response = session.get(REST_URL + 'auth/logout')
|
||||
|
||||
if response.status_code // 100 != 2: # error due to incorrect parameters, bad request, etc...
|
||||
print("Error requesting logout %s" % response.status_code)
|
||||
return -1
|
||||
|
||||
# Return value of logout method is nonsense (returns always done right now, but it's not important)
|
||||
|
||||
return 0
|
||||
|
||||
# Sample response from request_pools
|
||||
# [
|
||||
# {
|
||||
# u'initial_srvs': 0,
|
||||
# u'name': u'WinAdolfo',
|
||||
# u'max_srvs': 0,
|
||||
# u'comments': u'',
|
||||
# u'id': 6,
|
||||
# u'state': u'A',
|
||||
# u'user_services_count': 3,
|
||||
# u'cache_l2_srvs': 0,
|
||||
# u'service_id': 9,
|
||||
# u'provider_id': 2,
|
||||
# u'cache_l1_srvs': 0,
|
||||
# u'restrained': False}
|
||||
# ]
|
||||
|
||||
def request_services() -> dict[str, typing.Any]:
|
||||
response = session.get(REST_URL + 'connection')
|
||||
if response.status_code // 100 != 2:
|
||||
print("Error requesting services %s" % response.status_code)
|
||||
print(response.text)
|
||||
return {}
|
||||
|
||||
return response.json()
|
||||
|
||||
if __name__ == '__main__':
|
||||
if login() == 0: # If we can log in, will get the pools correctly
|
||||
res = request_services()
|
||||
print(res)
|
||||
print(logout())
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014 Virtual Cable S.L.
|
||||
# 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
|
||||
'''
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from httplib2 import Http
|
||||
import json
|
||||
import sys
|
||||
|
||||
rest_url = 'http://172.27.0.1:8000/rest/'
|
||||
|
||||
|
||||
|
||||
headers = {}
|
||||
|
||||
# Hace login con el root, puede usarse cualquier autenticador y cualquier usuario, pero en la 1.5 solo está implementado poder hacer
|
||||
# este tipo de login con el usuario "root"
|
||||
def login():
|
||||
global headers
|
||||
h = Http()
|
||||
|
||||
# parameters = '{ "auth": "admin", "username": "root", "password": "temporal" }'
|
||||
parameters = '{ "auth": "interna", "username": "admin", "password": "temporal" }'
|
||||
|
||||
resp, content = h.request(rest_url + 'auth/login', method='POST', body=parameters)
|
||||
|
||||
if resp['status'] != '200': # Authentication error due to incorrect parameters, bad request, etc...
|
||||
print "Authentication error"
|
||||
return -1
|
||||
|
||||
# resp contiene las cabeceras, content el contenido de la respuesta (que es json), pero aún está en formato texto
|
||||
res = json.loads(content)
|
||||
print "Authentication response: {}".format(res)
|
||||
if res['result'] != 'ok': # Authentication error
|
||||
print "Authentication error"
|
||||
sys.exit(1)
|
||||
|
||||
headers['X-Auth-Token'] = res['token']
|
||||
headers['content-type'] = 'application/json'
|
||||
|
||||
return 0
|
||||
|
||||
def logout():
|
||||
global headers
|
||||
h = Http()
|
||||
|
||||
resp, content = h.request(rest_url + 'auth/logout', headers=headers)
|
||||
|
||||
if resp['status'] != '200': # Logout error due to incorrect parameters, bad request, etc...
|
||||
print "Error requesting logout"
|
||||
return -1
|
||||
|
||||
# Return value of logout method is nonsense (returns always done right now, but it's not important)
|
||||
|
||||
return 0
|
||||
|
||||
def list_supported_auths_and_fields():
|
||||
h = Http()
|
||||
|
||||
resp, content = h.request(rest_url + 'authenticators/types', headers=headers)
|
||||
if resp['status'] != '200':
|
||||
print "Error in request: \n-------------------\n{}\n{}\n----------------".format(resp, content)
|
||||
sys.exit(1)
|
||||
|
||||
r = json.loads(content)
|
||||
|
||||
for auth in r: # r is an array
|
||||
print '* {}'.format(auth['name'])
|
||||
for fld in auth: # every auth is converted to a dictionary in python by json.load
|
||||
# Skip icon
|
||||
if fld != 'icon':
|
||||
print " > {}: {}".format(fld, auth[fld])
|
||||
resp, content = h.request(rest_url + 'authenticators/gui/{}'.format(auth['type']), headers=headers)
|
||||
if resp['status'] != '200':
|
||||
print "Error in request: \n-------------------\n{}\n{}\n----------------".format(resp, content)
|
||||
sys.exit(1)
|
||||
|
||||
print " > GUI"
|
||||
rr = json.loads(content)
|
||||
for field in rr:
|
||||
print " - Name: {}".format(field['name'])
|
||||
print " - Value: {}".format(field['value'])
|
||||
print " - GUI: "
|
||||
for gui in field['gui']:
|
||||
print " + {}: {}".format(gui, field['gui'][gui])
|
||||
print " > Simplified fields:"
|
||||
for field in rr:
|
||||
print " - Name: {}, Type: {}, is Required?: {}".format(field['name'], field['gui']['type'], field['gui']['required'])
|
||||
|
||||
def create_simpleldap_auth():
|
||||
h = Http()
|
||||
|
||||
# Keep in mind that parameters are related to kind of authenticator.
|
||||
# To ensure what parameters you need, yo can invoke first its gui
|
||||
# Take a look at list_supported_auths_and_fields method
|
||||
data = {"tags":["Tag1","Tag2","Tag3"],"name":"name_Field","comments":"comments__Field","priority":"1","small_name":"label_Field","host":"host_Field","port":"389","ssl":False,"timeout":"10","username":"username__Field","password":"password_Field","ldapBase":"base_Field","userClass":"userClass_Field","userIdAttr":"userIdAttr_Field","userNameAttr":"userName_Field","groupClass":"groupClass_Field","groupIdAttr":"groupId_Field","memberAttr":"groupMembership_Field","data_type":"SimpleLdapAuthenticator"}
|
||||
resp, content = h.request(rest_url + 'authenticators','PUT', headers=headers, body=json.dumps(data))
|
||||
if resp['status'] != '200':
|
||||
print "Error in request: \n-------------------\n{}\n{}\n----------------".format(resp, content)
|
||||
sys.exit(1)
|
||||
|
||||
# Expected content is something like this:
|
||||
# {
|
||||
# "numeric_id": 18,
|
||||
# "groupIdAttr": "groupId_Field",
|
||||
# "port": "389",
|
||||
# "memberAttr": "groupMembership_Field",
|
||||
# "id": "790b9d85-67ec-51dc-847f-dee1daa96a7c",
|
||||
# "userClass": "userClass_Field",
|
||||
# "permission": 96,
|
||||
# "comments": "comments__Field",
|
||||
# "users_count": 0,
|
||||
# "priority": "1",
|
||||
# "type": "SimpleLdapAuthenticator",
|
||||
# "username": "username__Field",
|
||||
# "ldapBase": "base_Field", "userNameAttr":
|
||||
# "userName_Field",
|
||||
# "tags": ["Tag1", "Tag2", "Tag3"],
|
||||
# "groupClass": "groupClass_Field",
|
||||
# "ssl": false,
|
||||
# "host": "host_Field",
|
||||
# "userIdAttr": "userIdAttr_Field",
|
||||
# "password": "password_Field",
|
||||
# "small_name": "label_Field",
|
||||
# "name": "name_Field",
|
||||
# "timeout": "10"
|
||||
# }
|
||||
r = json.loads(content)
|
||||
print "Correctly created {} with id {}".format(r['name'], r['id'])
|
||||
print "The record created was: {}".format(r)
|
||||
return r
|
||||
|
||||
def delete_auth(auth_id):
|
||||
h = Http()
|
||||
|
||||
# Sample delete URL for an auth
|
||||
# http://172.27.0.1:8000/rest/authenticators/790b9d85-67ec-51dc-847f-dee1daa96a7c
|
||||
# Method MUST be DELETE
|
||||
resp, content = h.request(rest_url + 'authenticators/{}'.format(auth_id), 'DELETE', headers=headers)
|
||||
if resp['status'] != '200':
|
||||
print "Error in request: \n-------------------\n{}\n{}\n----------------".format(resp, content)
|
||||
sys.exit(1)
|
||||
|
||||
print "Correctly deleted {}".format(auth_id)
|
||||
|
||||
def create_internal_auth():
|
||||
h = Http()
|
||||
|
||||
data = {"tags":[""],"name":"name_Field","comments":"comments_Field","priority":"1","small_name":"label_Field","differentForEachHost":False,"reverseDns":False,"acceptProxy":False,"data_type":"InternalDBAuth"}
|
||||
resp, content = h.request(rest_url + 'authenticators','PUT', headers=headers, body=json.dumps(data))
|
||||
if resp['status'] != '200':
|
||||
print "Error in request: \n-------------------\n{}\n{}\n----------------".format(resp, content)
|
||||
sys.exit(1)
|
||||
|
||||
r = json.loads(content)
|
||||
print "Correctly created {} with id {}".format(r['name'], r['id'])
|
||||
print "The record created was: {}".format(r)
|
||||
return r
|
||||
|
||||
def create_internal_group(auth_id):
|
||||
h = Http()
|
||||
|
||||
# Type can also be a metagroup, composed of groups, but for this sample a group is enoutgh
|
||||
data = {"type":"group","name":"groupname_Field","comments":"comments_Field","state":"A"}
|
||||
resp, content = h.request(rest_url + 'authenticators/{}/groups'.format(auth_id),'PUT', headers=headers, body=json.dumps(data))
|
||||
if resp['status'] != '200':
|
||||
print "Error in request: \n-------------------\n{}\n{}\n----------------".format(resp, content)
|
||||
sys.exit(1)
|
||||
|
||||
r = json.loads(content)
|
||||
print "Correctly created {} with id {}".format(r['name'], r['id'])
|
||||
print "The record created was: {}".format(r)
|
||||
return r
|
||||
|
||||
def delete_group(auth_id, group_id):
|
||||
h = Http()
|
||||
|
||||
# Method MUST be DELETE
|
||||
resp, content = h.request(rest_url + 'authenticators/{}/groups/{}'.format(auth_id, group_id), 'DELETE', headers=headers)
|
||||
if resp['status'] != '200':
|
||||
print "Error in request: \n-------------------\n{}\n{}\n----------------".format(resp, content)
|
||||
sys.exit(1)
|
||||
|
||||
print "Correctly deleted {}".format(auth_id)
|
||||
|
||||
|
||||
def create_internal_user(auth_id, group_id):
|
||||
# Note: internal users NEEDS to store password on UDS, description of auth describes if password field is needed (in this case, we need it)
|
||||
# Also, if authenticator is marked as "external" on its description, the groups field will be ignored.
|
||||
# On internal auths, we can incluide de ID of the groups we want this user to belong to, or it will not belong to any group
|
||||
h = Http()
|
||||
|
||||
data = {"id":"","name":"username_Field","real_name":"name_Field","comments":"comments_Field","state":"A","staff_member":False, "is_admin":False,"password":"password_Field","groups":[group_id]}
|
||||
|
||||
resp, content = h.request(rest_url + 'authenticators/{}/users'.format(auth_id),'PUT', headers=headers, body=json.dumps(data))
|
||||
if resp['status'] != '200':
|
||||
print "Error in request: \n-------------------\n{}\n{}\n----------------".format(resp, content)
|
||||
sys.exit(1)
|
||||
|
||||
r = json.loads(content)
|
||||
print "Correctly created {} with id {}".format(r['name'], r['id'])
|
||||
print "The record created was: {}".format(r)
|
||||
return r
|
||||
|
||||
def delete_user(auth_id, user_id):
|
||||
# Deleting user will result in deleting in cascade all asigned resources (machines, apps, etc...)
|
||||
|
||||
h = Http()
|
||||
|
||||
# Method MUST be DELETE
|
||||
resp, content = h.request(rest_url + 'authenticators/{}/users/{}'.format(auth_id, user_id), 'DELETE', headers=headers)
|
||||
if resp['status'] != '200':
|
||||
print "Error in request: \n-------------------\n{}\n{}\n----------------".format(resp, content)
|
||||
sys.exit(1)
|
||||
|
||||
print "Correctly deleted {}".format(auth_id)
|
||||
|
||||
def list_currents_auths():
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
if login() == 0: # If we can log in, will get the pools correctly
|
||||
print "Listing supported auths and related info"
|
||||
list_supported_auths_and_fields()
|
||||
print "*******************************"
|
||||
print "Creating a simple ldap authenticator"
|
||||
auth = create_simpleldap_auth()
|
||||
print "*******************************"
|
||||
print "Deleting the created simple ldap authenticator"
|
||||
delete_auth(auth['id'])
|
||||
print "*******************************"
|
||||
print "Creating internal auth"
|
||||
auth = create_internal_auth()
|
||||
print "*******************************"
|
||||
print "Creating internal group"
|
||||
print "*******************************"
|
||||
group = create_internal_group(auth['id'])
|
||||
print "Creating internal user"
|
||||
print "*******************************"
|
||||
user = create_internal_user(auth['id'], group['id'])
|
||||
print "*******************************"
|
||||
print "Deleting user"
|
||||
delete_user(auth['id'], user['id'])
|
||||
print "*******************************"
|
||||
print "Deleting Group"
|
||||
delete_group(auth['id'], group['id'])
|
||||
print "*******************************"
|
||||
print "Deleting the created internal auth"
|
||||
delete_auth(auth['id'])
|
||||
@@ -1,160 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2021 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 typing
|
||||
import collections.abc
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
|
||||
REST_URL: typing.Final[str] = 'http://172.27.0.1:8000/uds/rest/'
|
||||
|
||||
class RESTException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuthException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
class LogoutException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
# Hace login con el root, puede usarse cualquier autenticador y cualquier usuario, pero en la 1.5 solo está implementado poder hacer
|
||||
# este tipo de login con el usuario "root"
|
||||
async def login(session: aiohttp.ClientSession) -> None:
|
||||
# parameters = '{ "auth": "admin", "username": "root", "password": "temporal" }'
|
||||
# parameters = '{ "auth": "interna", "username": "admin", "password": "temporal" }'
|
||||
parameters = {'auth': 'interna', 'username': 'admin', 'password': 'temporal'}
|
||||
|
||||
response = await session.post(REST_URL + 'auth/login', json=parameters)
|
||||
|
||||
if not response.ok:
|
||||
raise AuthException('Error logging in')
|
||||
|
||||
# resp contiene las cabeceras, content el contenido de la respuesta (que es json), pero aún está en formato texto
|
||||
res = await response.json()
|
||||
print(res)
|
||||
|
||||
if res['result'] != 'ok': # Authentication error
|
||||
raise AuthException('Authentication error')
|
||||
|
||||
session.headers.update({'X-Auth-Token': res['token']})
|
||||
|
||||
|
||||
async def logout(session: aiohttp.ClientSession) -> None:
|
||||
response = await session.get(REST_URL + 'auth/logout')
|
||||
|
||||
if not response.ok:
|
||||
raise LogoutException('Error logging out')
|
||||
|
||||
|
||||
# Sample response from request_pools
|
||||
# [
|
||||
# {
|
||||
# u'initial_srvs': 0,
|
||||
# u'name': u'WinAdolfo',
|
||||
# u'max_srvs': 0,
|
||||
# u'comments': u'',
|
||||
# u'id': 6,
|
||||
# u'state': u'A',
|
||||
# u'user_services_count': 3,
|
||||
# u'cache_l2_srvs': 0,
|
||||
# u'service_id': 9,
|
||||
# u'provider_id': 2,
|
||||
# u'cache_l1_srvs': 0,
|
||||
# u'restrained': False}
|
||||
# ]
|
||||
|
||||
|
||||
async def request_pools(session: aiohttp.ClientSession) -> list[collections.abc.MutableMapping[str, typing.Any]]:
|
||||
response = await session.get(REST_URL + 'servicespools/overview')
|
||||
if not response.ok:
|
||||
raise RESTException('Error requesting pools')
|
||||
|
||||
return await response.json()
|
||||
|
||||
async def request_ticket(
|
||||
session: aiohttp.ClientSession,
|
||||
username: str,
|
||||
auth_label: str,
|
||||
groups: typing.Union[list[str], str],
|
||||
servicepool: str,
|
||||
real_name: typing.Optional[str] = None,
|
||||
transport: typing.Optional[str] = None,
|
||||
force: bool = False
|
||||
) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
data = {
|
||||
'username': username,
|
||||
'authSmallName': auth_label,
|
||||
'groups': groups,
|
||||
'servicePool': servicepool,
|
||||
'force': 'true' if force else 'false'
|
||||
}
|
||||
if real_name:
|
||||
data['realname'] = real_name
|
||||
if transport:
|
||||
data['transport'] = transport
|
||||
response = await session.put(
|
||||
REST_URL + 'tickets/create',
|
||||
json=data
|
||||
)
|
||||
if not response.ok:
|
||||
raise RESTException('Error requesting ticket: %s (%s)' % (response.status, response.reason))
|
||||
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def main():
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# request_pools() # Not logged in, this will generate an error
|
||||
await login(session) # Will raise an exception if error
|
||||
#pools = request_pools()
|
||||
#for i in pools:
|
||||
# print(i['id'], i['name'])
|
||||
ticket = await request_ticket(
|
||||
session=session,
|
||||
username='adolfo',
|
||||
auth_label='172.27.0.1:8000',
|
||||
groups=['adolfo', 'dkmaster'],
|
||||
servicepool='6201b357-c4cd-5463-891e-71441a25faee',
|
||||
real_name='Adolfo Gómez',
|
||||
force=True
|
||||
)
|
||||
print(ticket)
|
||||
|
||||
await logout(session)
|
||||
|
||||
if __name__ == "__main__":
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
@@ -1,142 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2021 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 typing
|
||||
import collections.abc
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
|
||||
REST_URL: typing.Final[str] = 'http://172.27.0.1:8000/uds/rest/'
|
||||
|
||||
|
||||
class RESTException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuthException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
class LogoutException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
# Hace login con el root, puede usarse cualquier autenticador y cualquier usuario, pero en la 1.5 solo está implementado poder hacer
|
||||
# este tipo de login con el usuario "root"
|
||||
async def login(session: aiohttp.ClientSession) -> None:
|
||||
# parameters = '{ "auth": "admin", "username": "root", "password": "temporal" }'
|
||||
# parameters = '{ "auth": "interna", "username": "admin", "password": "temporal" }'
|
||||
parameters = {'auth': 'interna', 'username': 'admin', 'password': 'temporal'}
|
||||
|
||||
response = await session.post(REST_URL + 'auth/login', json=parameters)
|
||||
|
||||
if not response.ok:
|
||||
raise AuthException('Error logging in')
|
||||
|
||||
# resp contiene las cabeceras, content el contenido de la respuesta (que es json), pero aún está en formato texto
|
||||
res = await response.json()
|
||||
print(res)
|
||||
|
||||
if res['result'] != 'ok': # Authentication error
|
||||
raise AuthException('Authentication error')
|
||||
|
||||
session.headers.update({'X-Auth-Token': res['token']})
|
||||
session.headers.update({'Scrambler': res['scrambler']})
|
||||
|
||||
# Fix user agent, so we indicate we are on Linux
|
||||
session.headers.update({'User-Agent': 'SampleClient/1.0 (Linux)'})
|
||||
|
||||
|
||||
async def logout(session: aiohttp.ClientSession) -> None:
|
||||
response = await session.get(REST_URL + 'auth/logout')
|
||||
|
||||
if not response.ok:
|
||||
raise LogoutException('Error logging out')
|
||||
|
||||
|
||||
async def list_services(
|
||||
session: aiohttp.ClientSession,
|
||||
) -> list[collections.abc.MutableMapping[str, typing.Any]]:
|
||||
response = await session.get(
|
||||
REST_URL + 'connection',
|
||||
)
|
||||
if not response.ok:
|
||||
raise RESTException(f'Error requesting ticket: {response.status} {response.reason}')
|
||||
|
||||
return (await response.json())['result']['services']
|
||||
|
||||
|
||||
async def get_uds_link(
|
||||
session: aiohttp.ClientSession,
|
||||
service_id: str,
|
||||
transport_id: str,
|
||||
) -> str:
|
||||
url = f'{REST_URL}connection/{service_id}/{transport_id}/udslink'
|
||||
print('Requesting ticket from', url)
|
||||
response = await session.get(
|
||||
url,
|
||||
)
|
||||
if not response.ok:
|
||||
raise RESTException(f'Error requesting ticket: {response.status} {response.reason}')
|
||||
|
||||
return (await response.json())['result']
|
||||
|
||||
|
||||
async def main():
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# request_pools() # Not logged in, this will generate an error
|
||||
await login(session) # Will raise an exception if error
|
||||
# pools = request_pools()
|
||||
# for i in pools:
|
||||
# print(i['id'], i['name'])
|
||||
services = await list_services(
|
||||
session=session,
|
||||
)
|
||||
for i in services:
|
||||
print(f'Service: {i}')
|
||||
service_id = i['id']
|
||||
transport_id = i['transports'][0]['id']
|
||||
print(f'Getting link for service {service_id} and transport {transport_id}')
|
||||
link = await get_uds_link(
|
||||
session=session,
|
||||
service_id=service_id,
|
||||
transport_id=transport_id,
|
||||
)
|
||||
print(f'Link is {link}')
|
||||
|
||||
await logout(session)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
@@ -1,181 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2021 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 typing
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import enum
|
||||
import argparse
|
||||
import json
|
||||
|
||||
REST_URL: str = 'http://172.27.0.1:8000/uds/rest/'
|
||||
|
||||
|
||||
class RESTException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuthException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
class LogoutException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
class CalendarActions(enum.StrEnum):
|
||||
PUBLISH = 'PUBLISH'
|
||||
CACHEL1 = 'CACHEL1'
|
||||
CACHEL2 = 'CACHEL2'
|
||||
INITIAL = 'INITIAL'
|
||||
MAX = 'MAX'
|
||||
ADD_TRANSPORT = 'ADD_TRANSPORT'
|
||||
REMOVE_TRANSPORT = 'REMOVE_TRANSPORT'
|
||||
REMOVE_ALL_TRANSPORTS = 'REMOVE_ALL_TRANSPORTS'
|
||||
ADD_GROUP = 'ADD_GROUP'
|
||||
REMOVE_GROUP = 'REMOVE_GROUP'
|
||||
REMOVE_ALL_GROUPS = 'REMOVE_ALL_GROUPS'
|
||||
IGNORE_UNUSED = 'IGNORE_UNUSED'
|
||||
REMOVE_USERSERVICES = 'REMOVE_USERSERVICES'
|
||||
STUCK_USERSERVICES = 'STUCK_USERSERVICES'
|
||||
CLEAN_CACHE_L1 = 'CLEAN_CACHE_L1'
|
||||
CLEAN_CACHE_L2 = 'CLEAN_CACHE_L2'
|
||||
|
||||
|
||||
# Hace login con el root, puede usarse cualquier autenticador y cualquier usuario, pero en la 1.5 solo está implementado poder hacer
|
||||
# este tipo de login con el usuario "root"
|
||||
async def login(session: aiohttp.ClientSession, auth: str, username: str, password: str) -> None:
|
||||
# parameters = '{ "auth": "admin", "username": "root", "password": "temporal" }'
|
||||
# parameters = '{ "auth": "interna", "username": "admin", "password": "temporal" }'
|
||||
# parameters = {'auth': 'interna', 'username': 'admin', 'password': 'temporal'}
|
||||
parameters = {'auth': auth, 'username': username, 'password': password}
|
||||
|
||||
response = await session.post(REST_URL + 'auth/login', json=parameters)
|
||||
|
||||
if not response.ok:
|
||||
raise AuthException('Error logging in')
|
||||
|
||||
# resp contiene las cabeceras, content el contenido de la respuesta (que es json), pero aún está en formato texto
|
||||
res = await response.json()
|
||||
print(res)
|
||||
|
||||
if res['result'] != 'ok': # Authentication error
|
||||
raise AuthException('Authentication error')
|
||||
|
||||
session.headers.update({'X-Auth-Token': res['token']})
|
||||
session.headers.update({'Scrambler': res['scrambler']})
|
||||
|
||||
# Fix user agent, so we indicate we are on Linux
|
||||
session.headers.update({'User-Agent': 'SampleClient/1.0 (Linux)'})
|
||||
|
||||
|
||||
async def logout(session: aiohttp.ClientSession) -> None:
|
||||
response = await session.get(REST_URL + 'auth/logout')
|
||||
|
||||
if not response.ok:
|
||||
raise LogoutException('Error logging out')
|
||||
|
||||
|
||||
async def add_calendar_action(
|
||||
session: aiohttp.ClientSession,
|
||||
service_pool_id: str,
|
||||
action: str,
|
||||
calendar_id: str,
|
||||
at_start: bool = True,
|
||||
events_offset: int = 0,
|
||||
params: typing.Optional[dict[str, typing.Any]] = None,
|
||||
) -> None:
|
||||
data = {
|
||||
'action': action,
|
||||
'calendar': '',
|
||||
'calendar_id': calendar_id,
|
||||
'at_start': at_start,
|
||||
'events_offset': events_offset,
|
||||
'params': params or {},
|
||||
}
|
||||
|
||||
# Headers are already set in session, so we only need to set the parameters
|
||||
response = await session.put(REST_URL + f'/servicespools/{service_pool_id}/actions', json=data)
|
||||
|
||||
if not response.ok:
|
||||
raise RESTException(f'Error adding calendar action: {response.status} {response.reason}')
|
||||
|
||||
|
||||
async def main():
|
||||
args = argparse.ArgumentParser()
|
||||
args.add_argument('--url', type=str, required=True)
|
||||
args.add_argument('--auth', type=str, required=True)
|
||||
args.add_argument('--username', type=str, required=True)
|
||||
args.add_argument('--password', type=str, required=True)
|
||||
args.add_argument('--service-pool-id', type=str, required=True)
|
||||
# Actions is one of the values of CalendarActions
|
||||
args.add_argument('--action', type=str, choices=list(CalendarActions), required=True)
|
||||
args.add_argument('--calendar-id', type=str, required=True)
|
||||
args.add_argument('--at-end', action='store_true', default=False)
|
||||
args.add_argument('--events-offset', type=int, default=0)
|
||||
args.add_argument('--params', type=str, default=None) # Must be a json string
|
||||
|
||||
options = args.parse_args()
|
||||
|
||||
if options.params is not None:
|
||||
options.params = json.loads(options.params)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# request_pools() # Not logged in, this will generate an error
|
||||
await login(session, options.auth, options.username, options.password)
|
||||
|
||||
# {"action":"PUBLISH","calendar":"","calendar_id":"370b5b59-687e-5a94-8c30-1c9eda6ac005","at_start":true,"events_offset":222,"params":{}}
|
||||
await add_calendar_action(
|
||||
session,
|
||||
service_pool_id=options.service_pool_id,
|
||||
action=options.action,
|
||||
calendar_id=options.calendar_id,
|
||||
at_start=not options.at_end,
|
||||
events_offset=options.events_offset,
|
||||
params=options.params,
|
||||
)
|
||||
# await add_calendar_action(
|
||||
# session,
|
||||
# service_pool_id='4f416484-711c-5faf-93b2-eb4a2eb3458e',
|
||||
# action='PUBLISH',
|
||||
# calendar_id='c1221a6d-3848-5fa3-ae98-172662c0f554',
|
||||
# at_start=True,
|
||||
# events_offset=222,
|
||||
# )
|
||||
|
||||
await logout(session)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2021 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 typing
|
||||
import requests
|
||||
import argparse
|
||||
import socket
|
||||
|
||||
REST_URL: typing.Final[str] = 'http{ssl}://{host}{port}/uds/rest/'
|
||||
|
||||
|
||||
class RESTException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuthException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
class LogoutException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
def register_with_broker(
|
||||
auth_uuid: str,
|
||||
username: str,
|
||||
password: str,
|
||||
broker_host: str,
|
||||
tunnel_ip: str,
|
||||
tunnel_hostname: typing.Optional[str] = None,
|
||||
broker_port: typing.Optional[int] = None,
|
||||
ssl: bool = True,
|
||||
verify: bool = True,
|
||||
) -> str:
|
||||
sport = (
|
||||
''
|
||||
if not broker_port
|
||||
else ':' + str(broker_port)
|
||||
if (ssl and broker_port != 443) or (not ssl and broker_port != 80)
|
||||
else ''
|
||||
)
|
||||
brokerURL = REST_URL.format(ssl='s' if ssl else '', host=broker_host, port=sport)
|
||||
print(f'Registering tunnel with broker at {brokerURL}')
|
||||
|
||||
tunnel_hostname = tunnel_hostname or socket.gethostname()
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
# First, try to login
|
||||
with session.post(
|
||||
brokerURL + '/auth/login',
|
||||
json={'auth_id': auth_uuid, 'username': username, 'password': password},
|
||||
verify=verify,
|
||||
) as r:
|
||||
if not r.ok:
|
||||
raise Exception('Invalid credentials supplied')
|
||||
session.headers.update({'X-Auth-Token': r.json()['token']})
|
||||
print('Logged in')
|
||||
|
||||
with session.post(
|
||||
brokerURL + '/tunnel/register',
|
||||
json={'ip': tunnel_ip, 'hostname': tunnel_hostname},
|
||||
verify=False,
|
||||
) as r:
|
||||
if r.ok:
|
||||
return r.json()['result']
|
||||
raise Exception(r.content)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Register a tunnel with UDS Broker')
|
||||
parser.add_argument(
|
||||
'--auth-uuid',
|
||||
help='UUID of authenticator to use',
|
||||
default='00000000-0000-0000-0000-000000000000',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--username',
|
||||
help='Username to use (must have administator privileges)',
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--password',
|
||||
help='Password to use',
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--broker-host',
|
||||
help='Broker host to connect to',
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--broker-port',
|
||||
help='Broker port to connect to',
|
||||
type=int,
|
||||
default=None,
|
||||
required=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--tunnel-ip',
|
||||
help='IP of tunnel server',
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--tunnel-hostname',
|
||||
help=f'Hostname of tunnel server (defaults to {socket.gethostname()})',
|
||||
required=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-ssl',
|
||||
help='Disable SSL in connection to broker',
|
||||
action='store_true',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-verify',
|
||||
help='Disable SSL certificate verification',
|
||||
action='store_true',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
token = register_with_broker(
|
||||
auth_uuid=args.auth_uuid,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
broker_host=args.broker_host,
|
||||
tunnel_ip=args.tunnel_ip,
|
||||
tunnel_hostname=args.tunnel_hostname,
|
||||
broker_port=args.broker_port,
|
||||
ssl=not args.no_ssl,
|
||||
verify=not args.no_verify,
|
||||
)
|
||||
print(f'Registered with token "{token}"')
|
||||
except Exception as e:
|
||||
print(f'Error registering tunnel: {e}')
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2024 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 typing
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
AUTH_NAME: typing.Final[str] = 'interna'
|
||||
AUTH_USER: typing.Final[str] = 'admin'
|
||||
AUTH_PASS: typing.Final[str] = 'temporal'
|
||||
|
||||
REST_URL: typing.Final[str] = 'http://172.27.0.1:8000/uds/rest/'
|
||||
|
||||
|
||||
class RESTException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuthException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
class LogoutException(RESTException):
|
||||
pass
|
||||
|
||||
|
||||
# Hace login con el root, puede usarse cualquier autenticador y cualquier usuario, pero en la 1.5 solo está implementado poder hacer
|
||||
# este tipo de login con el usuario "root"
|
||||
async def login(session: aiohttp.ClientSession) -> None:
|
||||
parameters = {
|
||||
'auth': AUTH_NAME,
|
||||
'username': AUTH_USER,
|
||||
'password': AUTH_PASS,
|
||||
}
|
||||
|
||||
response = await session.post(REST_URL + 'auth/login', json=parameters)
|
||||
|
||||
if not response.ok:
|
||||
raise AuthException('Error logging in')
|
||||
|
||||
# resp contiene las cabeceras, content el contenido de la respuesta (que es json), pero aún está en formato texto
|
||||
res = await response.json()
|
||||
print(res)
|
||||
|
||||
if res['result'] != 'ok': # Authentication error
|
||||
raise AuthException('Authentication error')
|
||||
|
||||
session.headers.update({'X-Auth-Token': res['token']})
|
||||
session.headers.update({'Scrambler': res['scrambler']})
|
||||
|
||||
# Fix user agent, so we indicate we are on Linux
|
||||
session.headers.update({'User-Agent': 'SampleClient/1.0 (Linux)'})
|
||||
|
||||
|
||||
async def logout(session: aiohttp.ClientSession) -> None:
|
||||
response = await session.get(REST_URL + 'auth/logout')
|
||||
|
||||
if not response.ok:
|
||||
raise LogoutException('Error logging out')
|
||||
|
||||
|
||||
async def set_config_var(section: str, name: str, value: str, session: aiohttp.ClientSession) -> None:
|
||||
response = await session.put(
|
||||
REST_URL + 'config',
|
||||
json={
|
||||
section: {
|
||||
name: {
|
||||
'value': value,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise RESTException('Error setting config var')
|
||||
|
||||
|
||||
async def main():
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await login(session) # Will raise an exception if error
|
||||
|
||||
# Get ipv4 and ipv6 from cloudflare
|
||||
ips: typing.List[str] = []
|
||||
for url in ['https://www.cloudflare.com/ips-v4', 'https://www.cloudflare.com/ips-v6']:
|
||||
response = await session.get(url)
|
||||
if not response.ok:
|
||||
raise RESTException('Error getting cloudflare ips')
|
||||
ips += (await response.text()).strip().split('\n')
|
||||
|
||||
await set_config_var('Security', 'Allowed IP Forwarders', ','.join(ips), session)
|
||||
|
||||
await logout(session)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
8
server/src/.gitignore
vendored
8
server/src/.gitignore
vendored
@@ -1,8 +0,0 @@
|
||||
/log/
|
||||
/static/
|
||||
.coverage
|
||||
/uds/static/clients/
|
||||
/*.sqlite3*
|
||||
.hypothesis
|
||||
htmlcov
|
||||
docs
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
|
||||
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
ASGI config for server project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -1,511 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
Settings file for uds server (Django)
|
||||
'''
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# calculated paths for django and the site
|
||||
# used as starting points for various other paths
|
||||
DJANGO_ROOT = os.path.dirname(os.path.realpath(django.__file__))
|
||||
BASE_DIR = '/'.join(
|
||||
os.path.dirname(os.path.abspath(__file__)).split('/')[:-1]
|
||||
) # If used 'relpath' instead of abspath, returns path of "enterprise" instead of "openuds"
|
||||
|
||||
DEBUG = True
|
||||
PROFILING = False
|
||||
|
||||
# USE_X_FORWARDED_HOST = True
|
||||
SECURE_PROXY_SSL_HEADER = (
|
||||
'HTTP_X_FORWARDED_PROTO',
|
||||
'https',
|
||||
) # For testing behind a reverse proxy
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.mysql', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
|
||||
'OPTIONS': {
|
||||
# 'init_command': 'SET default_storage_engine=INNODB; SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;',
|
||||
# 'init_command': 'SET storage_engine=INNODB, SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED',
|
||||
# 'init_command': 'SET storage_engine=MYISAM, SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED',
|
||||
'isolation_level': 'read committed',
|
||||
},
|
||||
'NAME': 'dbuds', # Or path to database file if using sqlite3.
|
||||
'USER': 'dbuds', # Not used with sqlite3.
|
||||
'PASSWORD': 'PASSWORD', # Not used with sqlite3.
|
||||
'HOST': 'localhost', # Set to empty string for localhost. Not used with sqlite3.
|
||||
'PORT': '3306', # Set to empty string for default. Not used with sqlite3.
|
||||
# 'CONN_MAX_AGE': 600, # Enable DB Pooling, 10 minutes max connection duration
|
||||
},
|
||||
'persistent': {
|
||||
'ENGINE': 'django.db.backends.sqlite3', # Persistent DB, used for persistent data
|
||||
'NAME': os.path.join(BASE_DIR, 'persistent.sqlite3'), # Path to persistent DB file
|
||||
'OPTIONS': {
|
||||
'timeout': 20, # Timeout for sqlite3 connections
|
||||
'transaction_mode': 'IMMEDIATE', # Use immediate transaction mode for better concurrency
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
# Local time zone for this installation. Choices can be found here:
|
||||
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||
# although not all choices may be available on all operating systems.
|
||||
# On Unix systems, a value of None will cause Django to use the same
|
||||
# timezone as the operating system.
|
||||
# If running in a Windows environment this must be set to the same as your
|
||||
# system time zone.
|
||||
|
||||
# TIME_SECTION_START
|
||||
USE_TZ = True
|
||||
TIME_ZONE = 'UTC'
|
||||
# TIME_SECTION_END
|
||||
|
||||
# Override for gettext so we can use the same syntax as in django
|
||||
# and we can translate it later with our own function
|
||||
def gettext(s):
|
||||
return s
|
||||
|
||||
# Language code for this installation. All choices can be found here:
|
||||
# http://www.i18nguy.com/unicode/language-identifiers.html
|
||||
# LANGUAGE_SECTION_START
|
||||
LANGUAGE_CODE = 'en'
|
||||
|
||||
LANGUAGES = (
|
||||
('es', gettext('Spanish')),
|
||||
('en', gettext('English')),
|
||||
('fr', gettext('French')),
|
||||
('de', gettext('German')),
|
||||
('pt', gettext('Portuguese')),
|
||||
('it', gettext('Italian')),
|
||||
('ar', gettext('Arabic')),
|
||||
('eu', gettext('Basque')),
|
||||
('ar', gettext('Arabian')),
|
||||
('ca', gettext('Catalan')),
|
||||
('zh-hans', gettext('Chinese')),
|
||||
)
|
||||
# LANGUAGE_SECTION_END
|
||||
|
||||
LANGUAGE_COOKIE_NAME = 'uds_lang'
|
||||
|
||||
# If you set this to False, Django will make some optimizations so as not
|
||||
# to load the internationalization machinery.
|
||||
USE_I18N = True
|
||||
|
||||
# If you set this to False, Django will not format dates, numbers and
|
||||
# calendars according to the current locale
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
# Absolute filesystem path to the directory that will hold user-uploaded files.
|
||||
# Example: "/home/media/media.lawrence.com/media/"
|
||||
MEDIA_ROOT = ''
|
||||
|
||||
# URL that handles the media served from MEDIA_ROOT. Make sure to use a
|
||||
# trailing slash.
|
||||
# Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
|
||||
MEDIA_URL = ''
|
||||
|
||||
# Absolute path to the directory static files should be collected to.
|
||||
# Don't put anything in this directory yourself; store your static files
|
||||
# in apps' "static/" subdirectories and in STATICFILES_DIRS.
|
||||
# Example: "/home/media/media.lawrence.com/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
|
||||
|
||||
# URL prefix for static files.
|
||||
# Example: "http://media.lawrence.com/static/"
|
||||
STATIC_URL = '/uds/res/'
|
||||
|
||||
# URL prefix for admin static files -- CSS, JavaScript and images.
|
||||
# Make sure to use a trailing slash.
|
||||
# Examples: "http://foo.com/static/admin/", "/static/admin/".
|
||||
# ADMIN_MEDIA_PREFIX = '/static/admin/'
|
||||
|
||||
# Additional locations of static files
|
||||
STATICFILES_DIRS = (
|
||||
# Put strings here, like "/home/html/static" or "C:/www/django/static".
|
||||
# Always use forward slashes, even on Windows.
|
||||
# Don't forget to use absolute paths, not relative paths.
|
||||
)
|
||||
|
||||
# List of finder classes that know how to find static files in
|
||||
# various locations.
|
||||
STATICFILES_FINDERS = (
|
||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||
# 'django.contrib.staticfiles.finders.DefaultStorageFinder',
|
||||
)
|
||||
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
|
||||
'LOCATION': 'uds_response_cache',
|
||||
'OPTIONS': {
|
||||
'MAX_ENTRIES': 5000,
|
||||
'CULL_FREQUENCY': 3, # 0 = Entire cache will be erased once MAX_ENTRIES is reached, this is faster on DB. if other value, will remove 1/this number items fromm cache
|
||||
},
|
||||
},
|
||||
# 'memory': {
|
||||
# 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
# }
|
||||
'memory': {
|
||||
'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
|
||||
'LOCATION': '127.0.0.1:11211',
|
||||
},
|
||||
}
|
||||
|
||||
# Update DB and CACHE if we are running tests
|
||||
# Note that this may need some adjustments depending on your environment
|
||||
if any(arg.endswith('test') or 'pytest/' in arg or '/pytest' in arg for arg in sys.argv) or 'PYTEST_XDIST_WORKER' in os.environ or 'TEST_UUID' in os.environ:
|
||||
DATABASES['default'] = {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'OPTIONS': {
|
||||
'timeout': 20,
|
||||
},
|
||||
'NAME': ':memory:',
|
||||
}
|
||||
CACHES['memory'] = {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
}
|
||||
|
||||
# Related to file uploading
|
||||
FILE_UPLOAD_PERMISSIONS = 0o640
|
||||
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o750
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 512 * 1024 # 512 Kb
|
||||
|
||||
# Make this unique, and don't share it with anybody.
|
||||
SECRET_KEY = 's5ky!7b5f#s35!e38xv%e-+iey6yi-#630x)kk3kk5_j8rie2*' # nosec: sample key, Remember to change it on production!!
|
||||
# **** NOTE!: The provided RSA Key is a Sample, a very old generated one and probably will not be accepted by your implementation of criptography ****
|
||||
# **** You MUST change this key to a new one, generated with the following command: ****
|
||||
# openssl genrsa -out private.pem 2048
|
||||
# **** And then, you must copy the contents of the file private.pem into the following variable ****
|
||||
RSA_KEY = '-----BEGIN RSA PRIVATE KEY-----\nMIICXgIBAAKBgQC0qe1GlriQbHFYdKYRPBFDSS8Ne/TEKI2mtPKJf36XZTy6rIyH\nvUpT1gMScVjHjOISLNJQqktyv0G+ZGzLDmfkCUBev6JBlFwNeX3Dv/97Q0BsEzJX\noYHiDANUkuB30ukmGvG0sg1v4ccl+xs2Su6pFSc5bGINBcQ5tO0ZI6Q1nQIDAQAB\nAoGBAKA7Octqb+T/mQOX6ZXNjY38wXOXJb44LXHWeGnEnvUNf/Aci0L0epCidfUM\nfG33oKX4BMwwTVxHDrsa/HaXn0FZtbQeBVywZqMqWpkfL/Ho8XJ8Rsq8OfElrwek\nOCPXgxMzQYxoNHw8V97k5qhfupQ+h878BseN367xSyQ8plahAkEAuPgAi6aobwZ5\nFZhx/+6rmQ8sM8FOuzzm6bclrvfuRAUFa9+kMM2K48NAneAtLPphofqI8wDPCYgQ\nTl7O96GXVQJBAPoKtWIMuBHJXKCdUNOISmeEvEzJMPKduvyqnUYv17tM0JTV0uzO\nuDpJoNIwVPq5c3LJaORKeCZnt3dBrdH1FSkCQQC3DK+1hIvhvB0uUvxWlIL7aTmM\nSny47Y9zsc04N6JzbCiuVdeueGs/9eXHl6f9gBgI7eCD48QAocfJVygphqA1AkEA\nrvzZjcIK+9+pJHqUO0XxlFrPkQloaRK77uHUaW9IEjui6dZu4+2T/q7SjubmQgWR\nZy7Pap03UuFZA2wCoqJbaQJAUG0FVrnyUORUnMQvdDjAWps2sXoPvA8sbQY1W8dh\nR2k4TCFl2wD7LutvsdgdkiH0gWdh5tc1c4dRmSX1eQ27nA==\n-----END RSA PRIVATE KEY-----'
|
||||
|
||||
# Trusted cyphers
|
||||
SECURE_CIPHERS = (
|
||||
'AES-256-GCM-SHA384'
|
||||
':CHACHA20-POLY1305-SHA256'
|
||||
':AES-128-GCM-SHA256'
|
||||
':ECDHE-RSA-AES256-GCM-SHA384'
|
||||
':ECDHE-RSA-AES128-GCM-SHA256'
|
||||
':ECDHE-RSA-CHACHA20-POLY1305'
|
||||
':ECDHE-ECDSA-AES128-GCM-SHA256'
|
||||
':ECDHE-ECDSA-AES256-GCM-SHA384'
|
||||
':ECDHE-ECDSA-CHACHA20-POLY1305'
|
||||
)
|
||||
# Min TLS version
|
||||
# SECURE_MIN_TLS_VERSION = '1.2'
|
||||
|
||||
# LDAP CIFHER SUITE can be enforced here. Use GNU TLS cipher suite names in this case
|
||||
# Debian libldap uses gnutls, and it's my development environment. Continue reading for more info:
|
||||
# i.e. (GNU TLS):
|
||||
# * NORMAL
|
||||
# * NORMAL:-VERS-TLS-ALL:+VERS-TLS1.2:+VERS-TLS1.3
|
||||
# * PFS
|
||||
# * SECURE256
|
||||
# If omitted, defaults to PFS:-VERS-TLS-ALL:+VERS-TLS1.2:+VERS-TLS1.3:-AES-128-CBC
|
||||
# Example:
|
||||
LDAP_CIPHER_SUITE = 'PFS:-VERS-TLS-ALL:+VERS-TLS1.2:+VERS-TLS1.3:-AES-128-CBC'
|
||||
# For OpenSSL python-ldap version, this is a valid starting point
|
||||
# LDAP_CIPHER_SUITE = 'HIGH:-SSLv2:-SSLv3:-TLSv1.0:-TLSv1.1:+TLSv1.2:+TLSv1.3'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [
|
||||
os.path.join(BASE_DIR, 'templates'),
|
||||
],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.i18n',
|
||||
'django.template.context_processors.media',
|
||||
'django.template.context_processors.static',
|
||||
'django.template.context_processors.tz',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.template.context_processors.request',
|
||||
],
|
||||
'debug': DEBUG,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'uds.middleware.request.GlobalRequestMiddleware',
|
||||
'uds.middleware.security.UDSSecurityMiddleware',
|
||||
'uds.middleware.xua.XUACompatibleMiddleware',
|
||||
'uds.middleware.redirect.RedirectMiddleware',
|
||||
]
|
||||
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
# SESSION_COOKIE_AGE = 3600
|
||||
SESSION_COOKIE_HTTPONLY = False
|
||||
SESSION_SERIALIZER = 'uds.core.util.session_serializer.SessionSerializer'
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
ROOT_URLCONF = 'server.urls'
|
||||
|
||||
# Python dotted path to the WSGI application used by Django's runserver.
|
||||
WSGI_APPLICATION = 'server.wsgi.application'
|
||||
# and asgi
|
||||
ASGI_APPLICATION = 'server.asgi.application'
|
||||
|
||||
INSTALLED_APPS = (
|
||||
# 'django.contrib.contenttypes', # Not used
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'uds.UDSAppConfig',
|
||||
)
|
||||
|
||||
# Tests runner is default tests runner
|
||||
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
|
||||
|
||||
# GUACAMOLE_DRIVE_NAME = 'UDSfs'
|
||||
|
||||
# See http://docs.djangoproject.com/en/dev/topics/logging for
|
||||
# more details on how to customize your logging configuration.
|
||||
LOGDIR = BASE_DIR + '/' + 'log'
|
||||
LOGFILE = 'uds.log'
|
||||
SERVICESFILE = 'services.log'
|
||||
WORKERSFILE = 'workers.log'
|
||||
AUTHFILE = 'auth.log'
|
||||
USEFILE = 'use.log'
|
||||
TRACEFILE = 'trace.log'
|
||||
OPERATIONSFILE = 'operations.log'
|
||||
LOGLEVEL = 'DEBUG' if DEBUG else 'INFO'
|
||||
ROTATINGSIZE = 32 * 1024 * 1024 # 32 Megabytes before rotating files
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': True,
|
||||
'filters': {
|
||||
'require_debug_false': {
|
||||
'()': 'django.utils.log.RequireDebugFalse',
|
||||
}
|
||||
},
|
||||
'formatters': {
|
||||
'database': {
|
||||
'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'
|
||||
},
|
||||
'simple': {
|
||||
'format': '%(levelname)s %(asctime)s %(name)s:%(funcName)s %(lineno)d %(message)s'
|
||||
},
|
||||
'uds': {
|
||||
'format': 'uds[%(process)-5s]: %(levelname)s %(asctime)s %(name)s:%(funcName)s %(lineno)d %(message)s'
|
||||
},
|
||||
'services': {
|
||||
'format': 'uds-s[%(process)-5s]: %(levelname)s %(asctime)s %(name)s:%(funcName)s %(lineno)d %(message)s'
|
||||
},
|
||||
'workers': {
|
||||
'format': 'uds-w[%(process)-5s]: %(levelname)s %(asctime)s %(name)s:%(funcName)s %(lineno)d %(message)s'
|
||||
},
|
||||
'auth': {'format': '%(asctime)s %(message)s'},
|
||||
'use': {'format': '%(asctime)s %(message)s'},
|
||||
'trace': {'format': '%(levelname)s %(asctime)s %(message)s'},
|
||||
},
|
||||
'handlers': {
|
||||
'null': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.NullHandler',
|
||||
},
|
||||
# Sample logging to syslog
|
||||
#'file': {
|
||||
# 'level': 'DEBUG',
|
||||
# 'class': 'logging.handlers.SysLogHandler',
|
||||
# 'formatter': 'uds',
|
||||
# 'facility': 'local0',
|
||||
# 'address': '/dev/log',
|
||||
#},
|
||||
'file': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'uds.core.util.log.UDSLogHandler',
|
||||
'formatter': 'simple',
|
||||
'filename': LOGDIR + '/' + LOGFILE,
|
||||
'mode': 'a',
|
||||
'maxBytes': ROTATINGSIZE,
|
||||
'backupCount': 3,
|
||||
'encoding': 'utf-8',
|
||||
},
|
||||
'database': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'formatter': 'database',
|
||||
'filename': LOGDIR + '/' + 'sql.log',
|
||||
'mode': 'a',
|
||||
'maxBytes': ROTATINGSIZE,
|
||||
'backupCount': 3,
|
||||
'encoding': 'utf-8',
|
||||
},
|
||||
'servicesFile': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'uds.core.util.log.UDSLogHandler',
|
||||
'formatter': 'simple',
|
||||
'filename': LOGDIR + '/' + SERVICESFILE,
|
||||
'mode': 'a',
|
||||
'maxBytes': ROTATINGSIZE,
|
||||
'backupCount': 3,
|
||||
'encoding': 'utf-8',
|
||||
},
|
||||
'workersFile': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'uds.core.util.log.UDSLogHandler',
|
||||
'formatter': 'simple',
|
||||
'filename': LOGDIR + '/' + WORKERSFILE,
|
||||
'mode': 'a',
|
||||
'maxBytes': ROTATINGSIZE,
|
||||
'backupCount': 3,
|
||||
'encoding': 'utf-8',
|
||||
},
|
||||
'authFile': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'uds.core.util.log.UDSLogHandler',
|
||||
'formatter': 'auth',
|
||||
'filename': LOGDIR + '/' + AUTHFILE,
|
||||
'mode': 'a',
|
||||
'maxBytes': ROTATINGSIZE,
|
||||
'backupCount': 3,
|
||||
'encoding': 'utf-8',
|
||||
},
|
||||
'useFile': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'uds.core.util.log.UDSLogHandler',
|
||||
'formatter': 'use',
|
||||
'filename': LOGDIR + '/' + USEFILE,
|
||||
'mode': 'a',
|
||||
'maxBytes': ROTATINGSIZE,
|
||||
'backupCount': 3,
|
||||
'encoding': 'utf-8',
|
||||
},
|
||||
'traceFile': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'uds.core.util.log.UDSLogHandler',
|
||||
'formatter': 'trace',
|
||||
'filename': LOGDIR + '/' + TRACEFILE,
|
||||
'mode': 'a',
|
||||
'maxBytes': ROTATINGSIZE,
|
||||
'backupCount': 3,
|
||||
'encoding': 'utf-8',
|
||||
},
|
||||
'operationsFile': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'uds.core.util.log.UDSLogHandler',
|
||||
'formatter': 'trace',
|
||||
'filename': LOGDIR + '/' + OPERATIONSFILE,
|
||||
'mode': 'a',
|
||||
'maxBytes': ROTATINGSIZE,
|
||||
'backupCount': 3,
|
||||
'encoding': 'utf-8',
|
||||
},
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'simple',
|
||||
},
|
||||
'mail_admins': {
|
||||
'level': 'ERROR',
|
||||
'class': 'django.utils.log.AdminEmailHandler',
|
||||
'filters': ['require_debug_false'],
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'': {
|
||||
'handlers': ['file'],
|
||||
'level': LOGLEVEL,
|
||||
},
|
||||
'django': {
|
||||
'handlers': ['null'],
|
||||
'propagate': True,
|
||||
'level': 'INFO',
|
||||
},
|
||||
'django.request': {
|
||||
'handlers': ['file'],
|
||||
'level': 'ERROR',
|
||||
'propagate': False,
|
||||
},
|
||||
'django.db.backends': {
|
||||
'handlers': ['database'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
# Disallow loggin "invalid hostname logging"
|
||||
'django.security.DisallowedHost': {
|
||||
'handlers': ['null'],
|
||||
'propagate': False,
|
||||
},
|
||||
# Disable fonttools (used by reports) logging (too verbose)
|
||||
'fontTools': {
|
||||
'handlers': ['null'],
|
||||
'propagate': True,
|
||||
'level': 'ERROR',
|
||||
},
|
||||
# Disable matplotlib (used by reports) logging (too verbose)
|
||||
'matplotlib': {
|
||||
'handlers': ['null'],
|
||||
'propagate': True,
|
||||
'level': 'ERROR',
|
||||
},
|
||||
'uds': {
|
||||
'handlers': ['file'],
|
||||
'level': LOGLEVEL,
|
||||
'propagate': False,
|
||||
},
|
||||
'uds.workers': {
|
||||
'handlers': ['workersFile'],
|
||||
'level': LOGLEVEL,
|
||||
'propagate': False,
|
||||
},
|
||||
'uds.core.jobs': {
|
||||
'handlers': ['workersFile'],
|
||||
'level': LOGLEVEL,
|
||||
'propagate': False,
|
||||
},
|
||||
'uds.services': {
|
||||
'handlers': ['servicesFile'],
|
||||
'level': LOGLEVEL,
|
||||
'propagate': False,
|
||||
},
|
||||
'uds.core.services': {
|
||||
'handlers': ['servicesFile'],
|
||||
'level': LOGLEVEL,
|
||||
'propagate': False,
|
||||
},
|
||||
# Custom Auth log
|
||||
'authLog': {
|
||||
'handlers': ['authFile'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
# Custom Services use log
|
||||
'useLog': {
|
||||
'handlers': ['useFile'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
# Custom tracing
|
||||
'traceLog': {
|
||||
'handlers': ['traceFile'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
# Custom operations
|
||||
'operationsLog': {
|
||||
'handlers': ['operationsFile'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Url patterns for UDS project (Django)
|
||||
"""
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path('', include('uds.urls')),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
WSGI config for server project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -1,35 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2012-2024 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
|
||||
"""
|
||||
# pyright: reportUnusedImport=false
|
||||
# Convenience imports, must be present before initializing handlers
|
||||
from .handlers import Handler
|
||||
from .dispatcher import Dispatcher
|
||||
@@ -1,282 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2012-2024 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 logging
|
||||
import sys
|
||||
import typing
|
||||
import collections.abc
|
||||
import traceback
|
||||
|
||||
from django import http
|
||||
import django
|
||||
import django.db
|
||||
import django.db.models
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic.base import View
|
||||
|
||||
from uds.core import consts, exceptions, types
|
||||
from uds.core.util import modfinder
|
||||
from uds.core.util.model import sql_stamp_seconds
|
||||
|
||||
from . import processors, log
|
||||
from .handlers import Handler
|
||||
from . import model as rest_model
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core.types.requests import ExtendedHttpRequestWithUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ['Handler', 'Dispatcher']
|
||||
|
||||
|
||||
class Dispatcher(View):
|
||||
"""
|
||||
This class is responsible of dispatching REST requests
|
||||
"""
|
||||
|
||||
# This attribute will contain all paths--> handler relations, filled at Initialized method
|
||||
root_node: typing.ClassVar[types.rest.HandlerNode] = types.rest.HandlerNode('', None, None, {})
|
||||
|
||||
@method_decorator(csrf_exempt)
|
||||
def dispatch(self, request: 'http.request.HttpRequest', path: str) -> 'http.HttpResponse':
|
||||
"""
|
||||
Processes the REST request and routes it wherever it needs to be routed
|
||||
"""
|
||||
request = typing.cast('ExtendedHttpRequestWithUser', request) # Reconverting to typed request
|
||||
if not hasattr(request, 'user'):
|
||||
raise exceptions.rest.HandlerError('Request does not have a user, cannot process request')
|
||||
|
||||
# Remove session from request, so response middleware do nothing with this
|
||||
del request.session
|
||||
|
||||
# Now we extract method and possible variables from path
|
||||
# path: list[str] = kwargs['arguments'].split('/')
|
||||
# path = kwargs['arguments']
|
||||
# del kwargs['arguments']
|
||||
|
||||
# # Transverse service nodes, so we can locate class processing this path
|
||||
# service = Dispatcher.services
|
||||
# full_path_lst: list[str] = []
|
||||
# # Guess content type from content type header (post) or ".xxx" to method
|
||||
content_type: str = request.META.get('CONTENT_TYPE', 'application/json').split(';')[0]
|
||||
|
||||
handler_node = Dispatcher.root_node.find_path(path)
|
||||
if not handler_node:
|
||||
return http.HttpResponseNotFound('Service not found', content_type="text/plain")
|
||||
|
||||
logger.debug("REST request: %s (%s)", handler_node, handler_node.full_path())
|
||||
|
||||
# Now, service points to the class that will process the request
|
||||
# We get the '' node, that is the "current" node, and get the class from it
|
||||
cls: typing.Optional[type[Handler]] = handler_node.handler
|
||||
if not cls:
|
||||
return http.HttpResponseNotFound('Method not found', content_type="text/plain")
|
||||
|
||||
processor = processors.available_processors_mime_dict.get(content_type, processors.default_processor)(
|
||||
request
|
||||
)
|
||||
|
||||
# Obtain method to be invoked
|
||||
http_method: str = request.method.lower() if request.method else ''
|
||||
# ensure method is recognized
|
||||
if http_method not in ('get', 'post', 'put', 'delete'):
|
||||
return http.HttpResponseNotAllowed(['GET', 'POST', 'PUT', 'DELETE'], content_type="text/plain")
|
||||
|
||||
node_full_path: typing.Final[str] = handler_node.full_path()
|
||||
|
||||
# Path here has "remaining" path, that is, method part has been removed
|
||||
args = path[len(node_full_path) :].split('/')[1:] # First element is always empty, so we skip it
|
||||
|
||||
handler: typing.Optional[Handler] = None
|
||||
|
||||
try:
|
||||
handler = cls(
|
||||
request,
|
||||
node_full_path,
|
||||
http_method,
|
||||
processor.process_parameters(),
|
||||
*args,
|
||||
)
|
||||
processor.set_odata(handler.odata)
|
||||
operation: collections.abc.Callable[[], typing.Any] = getattr(handler, http_method)
|
||||
except processors.ParametersException as e:
|
||||
logger.debug(
|
||||
'Path: %s',
|
||||
)
|
||||
logger.debug('Error: %s', e)
|
||||
|
||||
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseBadRequest(
|
||||
f'Invalid parameters invoking {handler_node.full_path()}: {e}',
|
||||
content_type="text/plain",
|
||||
)
|
||||
except AttributeError:
|
||||
allowed_methods: list[str] = [n for n in ['get', 'post', 'put', 'delete'] if hasattr(handler, n)]
|
||||
log.log_operation(handler, 405, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseNotAllowed(
|
||||
allowed_methods, content=b'{"error": "Invalid method"}', content_type="application/json"
|
||||
)
|
||||
except exceptions.rest.AccessDenied:
|
||||
log.log_operation(handler, 403, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseForbidden(b'{"error": "Access denied"}', content_type="application/json")
|
||||
except Exception:
|
||||
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
|
||||
logger.exception('error accessing attribute')
|
||||
logger.debug('Getting attribute %s for %s', http_method, handler_node.full_path())
|
||||
return http.HttpResponseServerError(
|
||||
b'{"error": "Unexpected error"}', content_type="application/json"
|
||||
)
|
||||
|
||||
# Invokes the handler's operation, add headers to response and returns
|
||||
try:
|
||||
response = operation()
|
||||
|
||||
# If response is an HttpResponse object, return it directly
|
||||
if not isinstance(response, http.HttpResponse):
|
||||
# If it is a generator, produce an streamed incremental response
|
||||
if isinstance(response, collections.abc.Generator):
|
||||
response = typing.cast(
|
||||
'http.HttpResponse',
|
||||
http.StreamingHttpResponse(
|
||||
processor.as_incremental(response),
|
||||
content_type="application/json",
|
||||
),
|
||||
)
|
||||
else:
|
||||
response = processor.get_response(response)
|
||||
# Set response headers
|
||||
response['UDS-Version'] = f'{consts.system.VERSION};{consts.system.VERSION_STAMP}'
|
||||
response['Response-Stamp'] = sql_stamp_seconds()
|
||||
for k, val in handler.headers().items():
|
||||
response[k] = val
|
||||
|
||||
# Log de operation on the audit log for admin
|
||||
# Exceptiol will also be logged, but with ERROR level
|
||||
log.log_operation(handler, response.status_code, types.log.LogLevel.INFO)
|
||||
return response
|
||||
# Note that the order of exceptions is important
|
||||
# because some exceptions are subclasses of others
|
||||
except exceptions.rest.NotSupportedError as e:
|
||||
log.log_operation(handler, 501, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseBadRequest(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.AccessDenied as e:
|
||||
log.log_operation(handler, 403, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseForbidden(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.NotFound as e:
|
||||
log.log_operation(handler, 404, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseNotFound(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.RequestError as e:
|
||||
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseBadRequest(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.ResponseError as e:
|
||||
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseServerError(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.HandlerError as e:
|
||||
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseBadRequest(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.services.generics.Error as e:
|
||||
log.log_operation(handler, 503, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseServerError(
|
||||
f'{{"error": "{e}"}}'.encode(), content_type="application/json", code=503
|
||||
)
|
||||
except django.db.models.Model.DoesNotExist as e: # All DoesNotExist exceptions are not found
|
||||
log.log_operation(handler, 404, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseNotFound(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except Exception as e:
|
||||
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
|
||||
# Get ecxeption backtrace
|
||||
trace_back = traceback.format_exc()
|
||||
logger.error('Exception processing request: %s', handler_node.full_path())
|
||||
for i in trace_back.splitlines():
|
||||
logger.error('* %s', i)
|
||||
|
||||
return http.HttpResponseServerError(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
|
||||
@staticmethod
|
||||
def register_handler(type_: type[Handler]) -> None:
|
||||
"""
|
||||
Method to register a class as a REST service
|
||||
param type_: Class to be registered
|
||||
"""
|
||||
if not type_.NAME:
|
||||
name = sys.intern(type_.__name__.lower())
|
||||
else:
|
||||
name = type_.NAME
|
||||
|
||||
# Fill the service_node tree with the class
|
||||
service_node = Dispatcher.root_node # Root path
|
||||
# If path, ensure that the path exists on the tree
|
||||
if type_.PATH:
|
||||
logger.info('Path: /%s/%s', type_.PATH, name)
|
||||
for k in type_.PATH.split('/'):
|
||||
intern_k = sys.intern(k)
|
||||
if intern_k not in service_node.children:
|
||||
service_node.children[intern_k] = types.rest.HandlerNode(k, None, service_node, {})
|
||||
service_node = service_node.children[intern_k]
|
||||
else:
|
||||
logger.info('Path: /%s', name)
|
||||
|
||||
if name not in service_node.children:
|
||||
service_node.children[name] = types.rest.HandlerNode(name, None, service_node, {})
|
||||
|
||||
service_node.children[name] = dataclasses.replace(service_node.children[name], handler=type_)
|
||||
|
||||
# Initializes the dispatchers
|
||||
@staticmethod
|
||||
def initialize() -> None:
|
||||
"""
|
||||
This imports all packages that are descendant of this package, and, after that,
|
||||
it register all subclases of Handler. (In fact, it looks for packages inside "methods" package, child of this)
|
||||
"""
|
||||
logger.info('Initializing REST Handlers')
|
||||
# Our parent module "REST", because we are in "dispatcher"
|
||||
module_name = __name__[: __name__.rfind('.')]
|
||||
|
||||
def checker(x: type[Handler]) -> bool:
|
||||
return not issubclass(x, rest_model.DetailHandler) and not x.__subclasses__()
|
||||
|
||||
# Register all subclasses of Handler
|
||||
modfinder.dynamically_load_and_register_packages(
|
||||
Dispatcher.register_handler,
|
||||
Handler,
|
||||
module_name=module_name,
|
||||
checker=checker,
|
||||
package_name='methods',
|
||||
)
|
||||
|
||||
logger.info('REST Handlers initialized')
|
||||
|
||||
|
||||
Dispatcher.initialize()
|
||||
@@ -1,511 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2012-2024 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 abc
|
||||
import typing
|
||||
import logging
|
||||
import codecs
|
||||
import collections.abc
|
||||
|
||||
from django.contrib.sessions.backends.base import SessionBase
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from uds.core import consts, types, exceptions
|
||||
from uds.core.util.config import GlobalConfig
|
||||
from uds.core.auths.auth import root_user
|
||||
from uds.core.util import net, query_db_filter, query_filter
|
||||
|
||||
from uds.models import Authenticator, User
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
|
||||
from ..core.exceptions.rest import AccessDenied
|
||||
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core.types.requests import ExtendedHttpRequestWithUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
|
||||
class Handler(abc.ABC):
|
||||
"""
|
||||
REST requests handler base class
|
||||
"""
|
||||
|
||||
NAME: typing.ClassVar[typing.Optional[str]] = (
|
||||
None # If name is not used, name will be the class name in lower case
|
||||
)
|
||||
PATH: typing.ClassVar[typing.Optional[str]] = (
|
||||
None # Path for this method, so we can do /auth/login, /auth/logout, /auth/auths in a simple way
|
||||
)
|
||||
|
||||
ROLE: typing.ClassVar[consts.UserRole] = consts.UserRole.USER # By default, only users can access
|
||||
|
||||
REST_API_INFO: typing.ClassVar[types.rest.api.RestApiInfo] = types.rest.api.RestApiInfo()
|
||||
|
||||
_request: 'ExtendedHttpRequestWithUser' # It's a modified HttpRequest
|
||||
_path: str
|
||||
_operation: str
|
||||
_params: dict[
|
||||
str, typing.Any
|
||||
] # This is a deserliazied object from request. Can be anything as 'a' or {'a': 1} or ....
|
||||
# These are the "path" split by /, that is, the REST invocation arguments
|
||||
_args: list[str]
|
||||
_headers: dict[
|
||||
str, str
|
||||
] # Note: These are "output" headers, not input headers (input headers can be retrieved from request)
|
||||
_session: typing.Optional[SessionStore]
|
||||
_auth_token: typing.Optional[str]
|
||||
_user: 'User'
|
||||
_odata: 'types.rest.api.ODataParams' # OData parameters, if any
|
||||
|
||||
# The dispatcher proceses the request and calls the method with the same name as the operation
|
||||
# currently, only 'get', 'post, 'put' y 'delete' are supported
|
||||
|
||||
# possible future:'patch', 'head', 'options', 'trace'
|
||||
def __init__(
|
||||
self,
|
||||
request: 'ExtendedHttpRequestWithUser',
|
||||
path: str,
|
||||
method: str,
|
||||
params: dict[str, typing.Any],
|
||||
*args: str,
|
||||
):
|
||||
self._request = request
|
||||
self._path = path
|
||||
self._operation = method
|
||||
self._params = params
|
||||
self._args = list(args) # copy of args
|
||||
self._headers = {}
|
||||
self._auth_token = None
|
||||
|
||||
if self.ROLE.needs_authentication:
|
||||
try:
|
||||
self._auth_token = self._request.headers.get(consts.auth.AUTH_TOKEN_HEADER, '')
|
||||
self._session = SessionStore(session_key=self._auth_token)
|
||||
if 'REST' not in self._session:
|
||||
raise Exception() # No valid session, so auth_token is also invalid
|
||||
except Exception: # Couldn't authenticate
|
||||
self._auth_token = None
|
||||
self._session = None
|
||||
|
||||
if self._auth_token is None:
|
||||
raise AccessDenied()
|
||||
|
||||
try:
|
||||
self._user = self.get_user()
|
||||
except Exception as e:
|
||||
# Maybe the user was deleted, so access is denied
|
||||
raise AccessDenied() from e
|
||||
|
||||
if not self._user.can_access(self.ROLE):
|
||||
raise AccessDenied()
|
||||
else:
|
||||
self._user = User() # Empty user for non authenticated handlers
|
||||
self._user.state = types.states.State.ACTIVE # Ensure it's active
|
||||
|
||||
if self._user and self._user.state != types.states.State.ACTIVE:
|
||||
raise AccessDenied()
|
||||
|
||||
self._odata = types.rest.api.ODataParams.from_dict(self.query_params())
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
"""
|
||||
Returns the headers of the REST request (all)
|
||||
"""
|
||||
return self._headers
|
||||
|
||||
def header(self, header_name: str) -> typing.Optional[str]:
|
||||
"""
|
||||
Get's an specific header name from REST request
|
||||
|
||||
Args:
|
||||
header_name: Name of header to retrieve
|
||||
|
||||
Returns:
|
||||
Value of header or None if not found
|
||||
"""
|
||||
return self._headers.get(header_name)
|
||||
|
||||
def query_params(self) -> dict[str, str | list[str]]:
|
||||
"""
|
||||
Returns the query parameters from the request (GET parameters)
|
||||
|
||||
Note:
|
||||
Dispatcher has it own parameters processor that fills our "_params".
|
||||
The processor tries to get from POST body json (or whatever), and, if not available
|
||||
from GET. So maybe this returns same values as _params, but, this always are GET parameters.
|
||||
Useful for odata fields ($filter, $skip, $top, $orderby)
|
||||
"""
|
||||
return {k: v[0] if len(v) == 1 else v for k, v in self._request.GET.lists()}
|
||||
|
||||
def add_header(self, header: str, value: str | int) -> None:
|
||||
"""
|
||||
Inserts a new header inside the headers list
|
||||
:param header: name of header to insert
|
||||
:param value: value of header
|
||||
"""
|
||||
self._headers[header] = str(value)
|
||||
|
||||
def delete_header(self, header: str) -> None:
|
||||
"""
|
||||
Removes an specific header from the headers list
|
||||
:param header: Name of header to remove
|
||||
"""
|
||||
try:
|
||||
del self._headers[header]
|
||||
except Exception: # nosec: intentionally ingoring exception
|
||||
pass # If not found, just ignore it
|
||||
|
||||
@property
|
||||
def request(self) -> 'ExtendedHttpRequestWithUser':
|
||||
"""
|
||||
Returns the request object
|
||||
"""
|
||||
return self._request
|
||||
|
||||
@property
|
||||
def params(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Returns the params object
|
||||
"""
|
||||
return self._params
|
||||
|
||||
@property
|
||||
def args(self) -> list[str]:
|
||||
"""
|
||||
Returns the args object
|
||||
"""
|
||||
return self._args
|
||||
|
||||
@property
|
||||
def odata(self) -> 'types.rest.api.ODataParams':
|
||||
return self._odata
|
||||
|
||||
@property
|
||||
def session(self) -> 'SessionStore':
|
||||
if self._session is None:
|
||||
raise Exception('No session available')
|
||||
return self._session
|
||||
|
||||
# Auth related
|
||||
def get_auth_token(self) -> typing.Optional[str]:
|
||||
"""
|
||||
Returns the authentication token for this REST request
|
||||
"""
|
||||
return self._auth_token
|
||||
|
||||
@staticmethod
|
||||
def set_rest_auth(
|
||||
session: SessionBase,
|
||||
id_auth: int,
|
||||
username: str,
|
||||
password: str,
|
||||
locale: str,
|
||||
platform: str,
|
||||
scrambler: str,
|
||||
) -> None:
|
||||
"""
|
||||
Stores the authentication data inside current session
|
||||
:param session: session handler (Djano user session object)
|
||||
:param id_auth: Authenticator id (DB object id)
|
||||
:param username: Name of user (login name)
|
||||
:param locale: Assigned locale
|
||||
:param is_admin: If user is considered admin or not
|
||||
:param staff_member: If is considered as staff member
|
||||
"""
|
||||
# crypt password and convert to base64
|
||||
passwd = codecs.encode(
|
||||
CryptoManager.manager().symmetric_encrypt(password, scrambler), 'base64'
|
||||
).decode()
|
||||
|
||||
session['REST'] = {
|
||||
'auth': id_auth,
|
||||
'username': username,
|
||||
'password': passwd,
|
||||
'locale': locale,
|
||||
'platform': platform,
|
||||
}
|
||||
|
||||
def gen_auth_token(
|
||||
self,
|
||||
id_auth: int,
|
||||
username: str,
|
||||
password: str,
|
||||
locale: str,
|
||||
platform: str,
|
||||
scrambler: str,
|
||||
) -> str:
|
||||
"""
|
||||
Generates the authentication token from a session, that is basically
|
||||
the session key itself
|
||||
:param id_auth: Authenticator id (DB object id)
|
||||
:param username: Name of user (login name)
|
||||
:param locale: Assigned locale
|
||||
:param is_admin: If user is considered admin or not
|
||||
:param staf_member: If user is considered staff member or not
|
||||
"""
|
||||
session = SessionStore()
|
||||
Handler.set_rest_auth(
|
||||
session,
|
||||
id_auth,
|
||||
username,
|
||||
password,
|
||||
locale,
|
||||
platform,
|
||||
scrambler,
|
||||
)
|
||||
session.save()
|
||||
self._auth_token = session.session_key
|
||||
self._session = session
|
||||
|
||||
return typing.cast(str, self._auth_token)
|
||||
|
||||
def clear_auth_token(self) -> None:
|
||||
"""
|
||||
Cleans up the authentication token
|
||||
"""
|
||||
self._auth_token = None
|
||||
if self._session:
|
||||
self._session.delete()
|
||||
self._session = None
|
||||
|
||||
# Session related (from auth token)
|
||||
def recover_value(self, key: str) -> typing.Any:
|
||||
"""
|
||||
Get REST session related value for a key
|
||||
"""
|
||||
try:
|
||||
if self._session:
|
||||
# if key is password, its in base64, so decode it and return as bytes
|
||||
if key == 'password':
|
||||
return codecs.decode(self._session['REST'][key], 'base64')
|
||||
return self._session['REST'].get(key)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def store_value(self, key: str, value: typing.Any) -> None:
|
||||
"""
|
||||
Set a session key value
|
||||
"""
|
||||
try:
|
||||
if self._session:
|
||||
# if key is password, its in base64, so encode it and store as str
|
||||
if key == 'password':
|
||||
self._session['REST'][key] = codecs.encode(value, 'base64').decode()
|
||||
else:
|
||||
self._session['REST'][key] = value
|
||||
self._session.accessed = True
|
||||
self._session.save()
|
||||
except Exception:
|
||||
logger.exception('Got an exception setting session value %s to %s', key, value)
|
||||
|
||||
def is_ip_allowed(self) -> bool:
|
||||
try:
|
||||
return net.contains(GlobalConfig.ADMIN_TRUSTED_SOURCES.get(True), self._request.ip)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'Error checking truted ADMIN source: "%s" does not seems to be a valid network string. Using Unrestricted access.',
|
||||
GlobalConfig.ADMIN_TRUSTED_SOURCES.get(),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def is_admin(self) -> bool:
|
||||
"""
|
||||
True if user of this REST request is administrator and SOURCE is valid admint trusted sources
|
||||
"""
|
||||
return bool(self.recover_value('is_admin')) and self.is_ip_allowed()
|
||||
|
||||
def is_staff_member(self) -> bool:
|
||||
"""
|
||||
True if user of this REST request is member of staff
|
||||
"""
|
||||
return bool(self.recover_value('staff_member')) and self.is_ip_allowed()
|
||||
|
||||
def get_user(self) -> 'User':
|
||||
"""
|
||||
If user is staff member, returns his Associated user on auth
|
||||
"""
|
||||
# logger.debug('REST : %s', self._session)
|
||||
auth_id = self.recover_value('auth')
|
||||
username = self.recover_value('username')
|
||||
# Maybe it's root user??
|
||||
if (
|
||||
GlobalConfig.SUPER_USER_ALLOW_WEBACCESS.as_bool(True)
|
||||
and username == GlobalConfig.SUPER_USER_LOGIN.get(True)
|
||||
and auth_id == -1
|
||||
):
|
||||
return root_user()
|
||||
|
||||
return Authenticator.objects.get(pk=auth_id).users.get(name=username)
|
||||
|
||||
def get_param(self, *names: str) -> str:
|
||||
"""
|
||||
Returns the first parameter found in the parameters (_params) list
|
||||
|
||||
Args:
|
||||
*names: List of names to search
|
||||
|
||||
Example:
|
||||
_params = {'username': 'uname_admin', 'name': 'name_admin'}
|
||||
get_param('name') will return 'admin'
|
||||
get_param('username', 'name') will return 'uname_admin'
|
||||
get_param('name', 'username') will return 'name_admin'
|
||||
get_param('other') will return ''
|
||||
"""
|
||||
for name in names:
|
||||
if name in self._params:
|
||||
return self._params[name]
|
||||
return ''
|
||||
|
||||
def get_sort_field_info(self, *args: str) -> tuple[str, bool]|None:
|
||||
"""
|
||||
Returns sorting information for the first sorting if it is contained in the odata orderby list.
|
||||
|
||||
Args:
|
||||
args: The possible name of the field name to check for sorting information.
|
||||
|
||||
Returns:
|
||||
A tuple containing the clean field name found and a boolean indicating if the sorting is descending,
|
||||
|
||||
Note:
|
||||
We only use the first in case of table sort translations, so this only returns info for the first field
|
||||
"""
|
||||
if self.odata.orderby:
|
||||
order_field = self.odata.orderby[0]
|
||||
clean_field = order_field.lstrip('-')
|
||||
for field_name in args:
|
||||
if clean_field == field_name:
|
||||
is_descending = order_field.startswith('-')
|
||||
return (clean_field, is_descending)
|
||||
return None
|
||||
|
||||
def apply_sort(self, qs: QuerySet[typing.Any]) -> list[typing.Any] | QuerySet[typing.Any]:
|
||||
"""
|
||||
Custom sorting function to apply to querysets.
|
||||
Override this method in subclasses to provide custom sorting logic.
|
||||
|
||||
Args:
|
||||
qs: The queryset to sort.
|
||||
order_by: The field name to sort by.
|
||||
|
||||
Returns:
|
||||
The sorted queryset.
|
||||
"""
|
||||
return qs.order_by(*self.odata.orderby)
|
||||
|
||||
@typing.final
|
||||
def filter_odata_queryset(self, qs: QuerySet[typing.Any]) -> list[typing.Any]:
|
||||
"""
|
||||
Filters the queryset based on odata
|
||||
|
||||
Note: We return a list, because after applying slicing, querysets may be evaluated
|
||||
by using _result_cache, so we force evaluation here to avoid issues later.
|
||||
"""
|
||||
# OData filter
|
||||
if self.odata.filter:
|
||||
try:
|
||||
qs = query_db_filter.exec_query(self.odata.filter, qs)
|
||||
except ValueError as e:
|
||||
raise exceptions.rest.RequestError(f'Invalid odata filter: {e}') from e
|
||||
|
||||
# Store total count before slicing
|
||||
self.add_header('X-Total-Count', str(qs.count()))
|
||||
|
||||
# order_by must be unique and all fields are summited by once
|
||||
# As after slicing we can have a list, we may use list result from sorting
|
||||
if self.odata.orderby:
|
||||
result = self.apply_sort(qs)
|
||||
else:
|
||||
result = qs
|
||||
|
||||
# If odata start/limit are set, apply them
|
||||
if self.odata.start is not None:
|
||||
result = result[self.odata.start :]
|
||||
# Note that limit is AFTER start because of previous line
|
||||
if self.odata.limit is not None:
|
||||
result = result[: self.odata.limit]
|
||||
|
||||
# After slicing, the qs may be a list, so we ensure it's a list
|
||||
# to avoid issues later
|
||||
result = list(result)
|
||||
|
||||
# Get total items and set it on X-Filtered-Count
|
||||
try:
|
||||
total_items = len(result)
|
||||
self.add_header('X-Filtered-Count', total_items)
|
||||
except Exception as e:
|
||||
raise exceptions.rest.RequestError(f'Invalid odata: {e}')
|
||||
|
||||
return result
|
||||
|
||||
def filter_odata_data(self, data: collections.abc.Iterable[T]) -> list[T]:
|
||||
"""
|
||||
Filters the dict base on the currnet odata
|
||||
"""
|
||||
if self.odata.filter:
|
||||
try:
|
||||
data = list(query_filter.exec_query(self.odata.filter, data))
|
||||
|
||||
except ValueError as e:
|
||||
raise exceptions.rest.RequestError(f'Invalid odata filter: {e}') from e
|
||||
else:
|
||||
data = list(data)
|
||||
|
||||
# Get total items and set it on X-Total-Count
|
||||
try:
|
||||
self.add_header('X-Total-Count', len(data))
|
||||
except Exception as e:
|
||||
raise exceptions.rest.RequestError(f'Invalid odata: {e}')
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
"""
|
||||
Returns the types that should be registered
|
||||
"""
|
||||
return types.rest.api.Components()
|
||||
|
||||
@classmethod
|
||||
def api_paths(
|
||||
cls: type[typing.Self], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
return {}
|
||||
@@ -1,108 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2022 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 typing
|
||||
|
||||
from uds import models
|
||||
from uds.core import consts
|
||||
|
||||
# Import for REST using this module can access constants easily
|
||||
# pylint: disable=unused-import
|
||||
from uds.core.util.log import LogLevel, LogSource, log
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .handlers import Handler
|
||||
|
||||
# This structct allows us to perform the following:
|
||||
# If path has ".../providers/[uuid]/..." we will replace uuid with "provider nanme" sourrounded by []
|
||||
# If path has ".../services/[uuid]/..." we will replace uuid with "service name" sourrounded by []
|
||||
# If path has ".../users/[uuid]/..." we will replace uuid with "user name" sourrounded by []
|
||||
# If path has ".../groups/[uuid]/..." we will replace uuid with "group name" sourrounded by []
|
||||
UUID_REPLACER: tuple[
|
||||
tuple[str, type[models.Provider | models.Service | models.ServicePool | models.User | models.Group]], ...
|
||||
] = (
|
||||
('providers', models.Provider),
|
||||
('services', models.Service),
|
||||
('servicespools', models.ServicePool),
|
||||
('users', models.User),
|
||||
('groups', models.Group),
|
||||
)
|
||||
|
||||
|
||||
def replace_path(path: str) -> str:
|
||||
"""Replaces uuids in path with names
|
||||
All paths are in the form .../type/uuid/...
|
||||
"""
|
||||
for type, model in UUID_REPLACER:
|
||||
if f'/{type}/' in path:
|
||||
try:
|
||||
uuid = path.split(f'/{type}/')[1].split('/')[0]
|
||||
name = model.objects.get(uuid=uuid).name
|
||||
path = path.replace(uuid, f'[{name}]')
|
||||
except Exception: # nosec: intentionally broad exception
|
||||
pass
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def log_operation(
|
||||
handler: typing.Optional['Handler'], response_code: int, level: LogLevel = LogLevel.INFO
|
||||
) -> None:
|
||||
"""
|
||||
Logs a request
|
||||
"""
|
||||
if not handler:
|
||||
return # Nothing to log
|
||||
|
||||
path = handler.request.path
|
||||
|
||||
# If a common request, and no error, we don't log it because it's useless and a waste of resources
|
||||
if response_code < 400 and any(
|
||||
x in path
|
||||
for x in (
|
||||
consts.rest.OVERVIEW,
|
||||
consts.rest.GUI,
|
||||
consts.rest.TABLEINFO,
|
||||
consts.rest.TYPES,
|
||||
consts.rest.SYSTEM,
|
||||
)
|
||||
):
|
||||
return
|
||||
|
||||
path = replace_path(path)
|
||||
|
||||
username = handler.request.user.pretty_name if handler.request.user else 'Unknown'
|
||||
log(
|
||||
None, # > None Objects goes to SYSLOG (global log)
|
||||
level=level,
|
||||
message=f'{handler.request.ip} [{username}]: [{handler.request.method}/{response_code}] {path}'[:4096],
|
||||
source=LogSource.REST,
|
||||
)
|
||||
@@ -1,139 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2017-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_lazy as _
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.REST.model import ModelHandler
|
||||
from uds.core import types
|
||||
import uds.core.types.permissions
|
||||
from uds.core.util import permissions, ensure, ui as ui_utils
|
||||
from uds.models import Account
|
||||
from .accountsusage import AccountsUsage
|
||||
|
||||
from django.db import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AccountItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
tags: typing.List[str]
|
||||
comments: str
|
||||
time_mark: typing.Optional[datetime.datetime]
|
||||
permission: int
|
||||
|
||||
|
||||
class Accounts(ModelHandler[AccountItem]):
|
||||
"""
|
||||
Processes REST requests about accounts
|
||||
"""
|
||||
|
||||
MODEL = Account
|
||||
DETAIL = {'usage': AccountsUsage}
|
||||
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('clear', True),
|
||||
types.rest.ModelCustomMethod('timemark', True),
|
||||
]
|
||||
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Accounts'))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.datetime_column(name='time_mark', title=_('Time mark'))
|
||||
.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.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_item(self, item: 'models.Model') -> AccountItem:
|
||||
item = ensure.is_instance(item, Account)
|
||||
return AccountItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
time_mark=item.time_mark,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
).build()
|
||||
|
||||
def timemark(self, item: 'models.Model') -> typing.Any:
|
||||
"""
|
||||
API:
|
||||
Generates a time mark associated with the account.
|
||||
This is useful to easily identify when the account data was last updated.
|
||||
(For example, one user enters an service, we get the usage data and "timemark" it, later read again
|
||||
and we can identify that all data before this timemark has already been processed)
|
||||
|
||||
Arguments:
|
||||
item: Account to timemark
|
||||
|
||||
"""
|
||||
item = ensure.is_instance(item, Account)
|
||||
item.time_mark = timezone.localtime()
|
||||
item.save()
|
||||
return ''
|
||||
|
||||
def clear(self, item: 'models.Model') -> typing.Any:
|
||||
"""
|
||||
Api documentation for the method. From here, will be used by the documentation generator
|
||||
Always starts with API:
|
||||
API:
|
||||
Clears all usage associated with the account
|
||||
"""
|
||||
item = ensure.is_instance(item, Account)
|
||||
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
return item.usages.filter(user_service=None).delete()
|
||||
@@ -1,136 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2017-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 as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import exceptions, types
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.models import Account, AccountUsage
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AccountItem(types.rest.BaseRestItem):
|
||||
uuid: str
|
||||
pool_uuid: str
|
||||
pool_name: str
|
||||
user_uuid: str
|
||||
user_name: str
|
||||
start: datetime.datetime
|
||||
end: datetime.datetime
|
||||
running: bool
|
||||
elapsed: str
|
||||
elapsed_timemark: str
|
||||
permission: int
|
||||
|
||||
|
||||
class AccountsUsage(DetailHandler[AccountItem]): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Detail handler for Services, whose parent is a Provider
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def as_dict(item: 'AccountUsage', perm: int) -> AccountItem:
|
||||
"""
|
||||
Convert an account usage to a dictionary
|
||||
:param item: Account usage item (db)
|
||||
:param perm: permission
|
||||
"""
|
||||
return AccountItem(
|
||||
uuid=item.uuid,
|
||||
pool_uuid=item.pool_uuid,
|
||||
pool_name=item.pool_name,
|
||||
user_uuid=item.user_uuid,
|
||||
user_name=item.user_name,
|
||||
start=item.start,
|
||||
end=item.end,
|
||||
running=item.user_service is not None,
|
||||
elapsed=item.elapsed,
|
||||
elapsed_timemark=item.elapsed_timemark,
|
||||
permission=perm,
|
||||
)
|
||||
|
||||
def get_item_position(self, parent: 'Model', item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, Account)
|
||||
return self.calc_item_position(item_uuid, parent.usages.all())
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult[AccountItem]:
|
||||
parent = ensure.is_instance(parent, Account)
|
||||
# Check what kind of access do we have to parent provider
|
||||
perm = permissions.effective_permissions(self._user, parent)
|
||||
return [AccountsUsage.as_dict(k, perm) for k in self.odata_filter(parent.usages.all())]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> AccountItem:
|
||||
parent = ensure.is_instance(parent, Account)
|
||||
# Check what kind of access do we have to parent provider
|
||||
return AccountsUsage.as_dict(
|
||||
parent.usages.get(uuid=process_uuid(item)), permissions.effective_permissions(self._user, parent)
|
||||
)
|
||||
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, Account)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Usages of {0}').format(parent.name))
|
||||
.text_column(name='pool_name', title=_('Pool name'))
|
||||
.text_column(name='user_name', title=_('User name'))
|
||||
.text_column(name='running', title=_('Running'))
|
||||
.datetime_column(name='start', title=_('Starts'))
|
||||
.datetime_column(name='end', title=_('Ends'))
|
||||
.text_column(name='elapsed', title=_('Elapsed'))
|
||||
.datetime_column(name='elapsed_timemark', title=_('Elapsed timemark'))
|
||||
.row_style(prefix='row-running-', field='running')
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> AccountItem:
|
||||
raise exceptions.rest.RequestError('Accounts usage cannot be edited')
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, Account)
|
||||
logger.debug('Deleting account usage %s from %s', item, parent)
|
||||
try:
|
||||
usage = parent.usages.get(uuid=process_uuid(item))
|
||||
usage.delete()
|
||||
except Exception:
|
||||
logger.error('Error deleting account usage %s from %s', item, parent)
|
||||
raise exceptions.rest.NotFound(_('Account usage not found: {}').format(item)) from None
|
||||
@@ -1,132 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2021 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_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from uds.core import types, consts
|
||||
from uds.core.types import permissions
|
||||
from uds.core.util import ensure, ui as ui_utils
|
||||
from uds.core.util.log import LogLevel
|
||||
from uds.models import Server
|
||||
from uds.core.exceptions.rest import NotFound, RequestError
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /osm path
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ActorTokenItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
stamp: datetime.datetime
|
||||
username: str
|
||||
ip: str
|
||||
host: str
|
||||
hostname: str
|
||||
version: str
|
||||
pre_command: str
|
||||
post_command: str
|
||||
run_once_command: str
|
||||
log_level: str
|
||||
os: str
|
||||
|
||||
|
||||
class ActorTokens(ModelHandler[ActorTokenItem]):
|
||||
|
||||
MODEL = Server
|
||||
FILTER = {'type': types.servers.ServerType.ACTOR}
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Actor tokens'))
|
||||
.datetime_column('stamp', _('Date'))
|
||||
.text_column('username', _('Issued by'))
|
||||
.text_column('host', _('Origin'))
|
||||
.text_column('version', _('Version'))
|
||||
.text_column('hostname', _('Hostname'))
|
||||
.text_column('pre_command', _('Pre-connect'))
|
||||
.text_column('post_command', _('Post-Configure'))
|
||||
.text_column('run_once_command', _('Run Once'))
|
||||
.text_column('log_level', _('Log level'))
|
||||
.text_column('os', _('OS'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_item(self, item: 'models.Model') -> ActorTokenItem:
|
||||
item = ensure.is_instance(item, Server)
|
||||
data: dict[str, typing.Any] = item.data or {}
|
||||
if item.log_level < 10000: # Old log level, from actor, etc..
|
||||
log_level = LogLevel.from_actor_level(item.log_level).name
|
||||
else:
|
||||
log_level = LogLevel(item.log_level).name
|
||||
return ActorTokenItem(
|
||||
id=item.token,
|
||||
name=str(_('Token isued by {} from {}')).format(
|
||||
item.register_username, item.hostname or item.ip
|
||||
),
|
||||
stamp=item.stamp,
|
||||
username=item.register_username,
|
||||
ip=item.ip,
|
||||
host=f'{item.ip} - {data.get("mac")}',
|
||||
hostname=item.hostname,
|
||||
version=item.version,
|
||||
pre_command=data.get('pre_command', ''),
|
||||
post_command=data.get('post_command', ''),
|
||||
run_once_command=data.get('run_once_command', ''),
|
||||
log_level=log_level,
|
||||
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(), permissions.PermissionType.ALL, root=True
|
||||
) # Must have write permissions to delete
|
||||
|
||||
try:
|
||||
self.MODEL.objects.get(token=self._args[0]).delete()
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise NotFound('Element do not exists') from None
|
||||
|
||||
return consts.OK
|
||||
@@ -1,934 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-2021 Virtual Cable S.L.
|
||||
# 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 functools
|
||||
import logging
|
||||
import time
|
||||
import typing
|
||||
import collections.abc
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
# from uds.core import VERSION
|
||||
from uds.core import consts, exceptions, osmanagers, types
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core.managers.userservice import UserServiceManager
|
||||
from uds.core.util import log, security
|
||||
from uds.core.util.cache import Cache
|
||||
from uds.core.util.config import GlobalConfig
|
||||
from uds.core.util.model import sql_now
|
||||
from uds.core.types.states import State
|
||||
from uds.models import Server, Service, TicketStore, UserService
|
||||
from uds.models.service import ServiceTokenAlias
|
||||
from uds.REST.utils import rest_result
|
||||
|
||||
from ..handlers import Handler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core import services
|
||||
from uds.core.types.requests import ExtendedHttpRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache the "failed login attempts" for a given IP
|
||||
cache = Cache('actorv3')
|
||||
|
||||
|
||||
# Helpers
|
||||
def get_list_of_ids(handler: 'Handler') -> list[str]:
|
||||
"""
|
||||
Comment:
|
||||
Due to database case sensitiveness, we need to check for both upper and lower case
|
||||
|
||||
Returns the list of ids, first returns the macs (alphabetically ordered) and then the ips (alphabetically ordered)
|
||||
"""
|
||||
set_of_ids = set(
|
||||
i.lower()
|
||||
for i in typing.cast(
|
||||
list[str],
|
||||
['1' + x['mac'] for x in handler._params['id']]
|
||||
+ ['0' + x['ip'] for x in handler._params['id']][:10],
|
||||
)
|
||||
)
|
||||
|
||||
return list(
|
||||
map(
|
||||
lambda x: x[1:],
|
||||
sorted(
|
||||
set_of_ids | {i.upper() for i in set_of_ids},
|
||||
reverse=True, # So lower case goes first
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def check_ip_is_blocked(request: 'ExtendedHttpRequest') -> None:
|
||||
if GlobalConfig.BLOCK_ACTOR_FAILURES.as_bool() is False:
|
||||
return
|
||||
fails = cache.get(request.ip) or 0
|
||||
if fails >= consts.system.ALLOWED_FAILS:
|
||||
logger.info(
|
||||
'Access to actor from %s is blocked for %s seconds since last fail',
|
||||
request.ip,
|
||||
GlobalConfig.LOGIN_BLOCK.as_int(),
|
||||
)
|
||||
# Sleep a while to try to minimize brute force attacks somehow
|
||||
time.sleep(3) # 3 seconds should be enough
|
||||
raise exceptions.rest.BlockAccess()
|
||||
|
||||
|
||||
def increase_failed_ip_count(request: 'ExtendedHttpRequest') -> None:
|
||||
fails = cache.get(request.ip, 0) + 1
|
||||
cache.put(request.ip, fails, GlobalConfig.LOGIN_BLOCK.as_int())
|
||||
|
||||
|
||||
P = typing.ParamSpec('P')
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
|
||||
# Decorator that clears failed counter for the IP if succeeds
|
||||
def clear_on_success(func: collections.abc.Callable[P, T]) -> collections.abc.Callable[P, T]:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
_self = typing.cast('ActorV3Action', args[0])
|
||||
result = func(
|
||||
*args, **kwargs
|
||||
) # If raises any exception, it will be raised and we will not clear the counter
|
||||
clear_failed_ip_counter(_self._request) # pylint: disable=protected-access
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def clear_failed_ip_counter(request: 'ExtendedHttpRequest') -> None:
|
||||
cache.remove(request.ip)
|
||||
|
||||
|
||||
class ActorV3Action(Handler):
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
PATH = 'actor/v3'
|
||||
NAME = 'actorv3'
|
||||
|
||||
@staticmethod
|
||||
def actor_result(result: typing.Any = None, **kwargs: typing.Any) -> dict[str, typing.Any]:
|
||||
return rest_result(result=result, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def set_comms_endpoint(userservice: UserService, ip: str, port: int, secret: str) -> None:
|
||||
userservice.set_comms_info(f'https://{ip}:{port}/actor/{secret}', secret)
|
||||
|
||||
@staticmethod
|
||||
def actor_cert_result(key: str, certificate: str, password: str) -> dict[str, typing.Any]:
|
||||
return ActorV3Action.actor_result(
|
||||
{
|
||||
'private_key': key, # To be removed on 5.0
|
||||
'key': key,
|
||||
'server_certificate': certificate, # To be removed on 5.0
|
||||
'certificate': certificate,
|
||||
'password': password,
|
||||
'ciphers': getattr(settings, 'SECURE_CIPHERS', ''),
|
||||
}
|
||||
)
|
||||
|
||||
def get_userservice(self) -> UserService:
|
||||
'''
|
||||
Looks for an userservice and, if not found, raises a exceptions.rest.BlockAccess request
|
||||
'''
|
||||
try:
|
||||
return UserService.objects.get(uuid=self._params['token'])
|
||||
except UserService.DoesNotExist:
|
||||
logger.error('User service not found (params: %s)', self._params)
|
||||
raise exceptions.rest.BlockAccess() from None
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
return ActorV3Action.actor_result(error='Base action invoked')
|
||||
|
||||
def post(self) -> dict[str, typing.Any]:
|
||||
try:
|
||||
check_ip_is_blocked(self._request)
|
||||
result = self.action()
|
||||
logger.debug('Action result: %s', result)
|
||||
return result
|
||||
except (exceptions.rest.BlockAccess, KeyError):
|
||||
# For blocking attacks
|
||||
increase_failed_ip_count(self._request)
|
||||
except Exception as e:
|
||||
logger.exception('Posting %s: %s', self.__class__, e)
|
||||
|
||||
raise exceptions.rest.AccessDenied('Access denied')
|
||||
|
||||
# Some helpers
|
||||
def notify_service(self, action: types.rest.actor.NotifyActionType) -> None:
|
||||
"""
|
||||
Notifies the Service (not userservice) that an action has been performed
|
||||
|
||||
This method will raise an exception if the service is not found or if the action is not valid
|
||||
|
||||
Args:
|
||||
action (NotifyActionType): Action to notify
|
||||
|
||||
Raises:
|
||||
exceptions.rest.BlockAccess: If the service is not found or the action is not valid
|
||||
|
||||
"""
|
||||
try:
|
||||
# If unmanaged, use Service locator
|
||||
service: 'services.Service' = Service.objects.get(token=self._params['token']).get_instance()
|
||||
|
||||
# We have a valid service, now we can make notifications
|
||||
|
||||
# Build the possible ids and make initial filter to match service
|
||||
# Note, for sure, first will be the firt mac (or ip if no macs) alphabetically ordered
|
||||
ids_list = get_list_of_ids(self)
|
||||
|
||||
# ensure idsLists has upper and lower versions for case sensitive databases
|
||||
|
||||
service_id: typing.Optional[str] = service.get_valid_id(ids_list)
|
||||
|
||||
is_remote = self._params.get('session_type', '')[:4] in ('xrdp', 'RDP-')
|
||||
|
||||
# Must be valid
|
||||
if action in (types.rest.actor.NotifyActionType.LOGIN, types.rest.actor.NotifyActionType.LOGOUT):
|
||||
if not service_id: # For login/logout, we need a valid id
|
||||
raise Exception()
|
||||
# Notify Service that someone logged in/out
|
||||
|
||||
if action == types.rest.actor.NotifyActionType.LOGIN:
|
||||
# Try to guess if this is a remote session
|
||||
service.process_login(service_id, remote_login=is_remote)
|
||||
elif action == types.rest.actor.NotifyActionType.LOGOUT:
|
||||
service.process_logout(service_id, remote_login=is_remote)
|
||||
elif action == types.rest.actor.NotifyActionType.DATA:
|
||||
service.notify_data(service_id, self._params['data'])
|
||||
else:
|
||||
raise Exception('Invalid action')
|
||||
|
||||
# All right, service notified..
|
||||
except Exception as e:
|
||||
# Log error and continue
|
||||
logger.error('Error notifying service: %s (%s)', e, self._params)
|
||||
raise exceptions.rest.BlockAccess() from None
|
||||
|
||||
|
||||
class Test(ActorV3Action):
|
||||
"""
|
||||
Tests UDS Broker actor connectivity & key
|
||||
"""
|
||||
|
||||
NAME = 'test'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
# First, try to locate an user service providing this token.
|
||||
try:
|
||||
if self._params.get('type') == consts.actor.UNMANAGED:
|
||||
Service.objects.get(token=self._params['token'])
|
||||
else:
|
||||
Server.objects.get(
|
||||
token=self._params['token'], type=types.servers.ServerType.ACTOR
|
||||
) # Not assigned, because only needs check
|
||||
clear_failed_ip_counter(self._request)
|
||||
except Exception as e:
|
||||
logger.info('Test host request: %s, %s', self._params, e)
|
||||
# Increase failed attempts
|
||||
increase_failed_ip_count(self._request)
|
||||
# And return test failed
|
||||
return ActorV3Action.actor_result('invalid token', error='invalid token')
|
||||
|
||||
return ActorV3Action.actor_result('ok')
|
||||
|
||||
|
||||
class Register(ActorV3Action):
|
||||
"""
|
||||
Registers an actor
|
||||
parameters:
|
||||
- mac: mac address of the registering machine
|
||||
- ip: ip address of the registering machine
|
||||
- hostname: hostname of the registering machine
|
||||
- pre_command: command to be executed before the connection of the user is established
|
||||
- post_command: command to be executed after the actor is initialized and before set ready
|
||||
- run_once_command: comand to run just once after the actor is started. The actor will stop after this.
|
||||
The command is responsible to restart the actor.
|
||||
- log_level: log level for the actor
|
||||
- custom: Custom actor data (i.e. cetificate and comms_url for LinxApps, maybe other for other services)
|
||||
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
NAME = 'register'
|
||||
|
||||
def post(self) -> dict[str, typing.Any]:
|
||||
# If already exists a token for this MAC, return it instead of creating a new one, and update the information...
|
||||
# For actors we use MAC instead of IP, because VDI normally is a dynamic IP, and we do "our best" to locate the existing actor
|
||||
# Look for a token for this mac. mac is "inside" data, so we must filter first by type and then ensure mac is inside data
|
||||
# and mac is the requested one
|
||||
found = False
|
||||
actor_token: typing.Optional[Server] = Server.objects.filter(
|
||||
type=types.servers.ServerType.ACTOR, mac=self._params['mac']
|
||||
).first()
|
||||
|
||||
# Try to get version from headers (USer-Agent), should be something like (UDS Actor v(.+))
|
||||
user_agent = self._request.headers.get('User-Agent', '')
|
||||
match = re.search(r'UDS Actor v(.+)', user_agent)
|
||||
if match:
|
||||
self._params['version'] = self._params.get(
|
||||
'version', match.group(1)
|
||||
) # override version if not provided
|
||||
|
||||
# Actors does not support any SERVER API version in fact, they has their own interfaces on UserServices
|
||||
# This means that we can invoke its API from user_service, but not from server (The actor token is transformed as soon as initialized to a user service token)
|
||||
|
||||
# New model has "commands" field in data, old one not
|
||||
if 'commands' in self._params:
|
||||
commands = self._params['commands']
|
||||
data = {
|
||||
'pre_command': commands.get('pre_command') or '',
|
||||
'post_command': commands.get('post_command') or '',
|
||||
'run_once_command': commands.get('run_once_command') or '',
|
||||
'custom': self._params.get('custom') or '',
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
'pre_command': self._params['pre_command'],
|
||||
'post_command': self._params['post_command'],
|
||||
'run_once_command': self._params['run_once_command'],
|
||||
'custom': self._params.get('custom', ''),
|
||||
}
|
||||
|
||||
if actor_token:
|
||||
# Update parameters
|
||||
# type is already set
|
||||
actor_token.subtype = self._params.get('subtype', '')
|
||||
actor_token.version = self._params.get('version', '')
|
||||
actor_token.register_username = self._user.pretty_name
|
||||
actor_token.register_ip = self._request.ip
|
||||
actor_token.ip = self._params['ip']
|
||||
actor_token.hostname = self._params['hostname']
|
||||
actor_token.log_level = self._params['log_level']
|
||||
actor_token.data = data
|
||||
actor_token.stamp = sql_now()
|
||||
actor_token.os_type = self._params.get('os', types.os.KnownOS.UNKNOWN.os_name())[:31]
|
||||
# Mac is already set, as type was used to locate it
|
||||
actor_token.save()
|
||||
logger.info('Registered actor %s', self._params)
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
kwargs = {
|
||||
'type': types.servers.ServerType.ACTOR,
|
||||
'subtype': self._params.get('subtype', ''),
|
||||
'version': self._params.get('version', ''),
|
||||
'register_username': self._user.pretty_name,
|
||||
'register_ip': self._request.ip,
|
||||
'ip': self._params['ip'],
|
||||
'hostname': self._params['hostname'],
|
||||
'log_level': self._params['log_level'],
|
||||
'data': data,
|
||||
# 'token': Server.create_token(), # Not needed, defaults to create_token
|
||||
'stamp': sql_now(),
|
||||
'os_type': self._params.get('os', types.os.KnownOS.UNKNOWN.os_name()),
|
||||
'mac': self._params['mac'],
|
||||
}
|
||||
|
||||
actor_token = Server.objects.create(**kwargs)
|
||||
|
||||
return ActorV3Action.actor_result(actor_token.token) # type: ignore # actorToken is always assigned
|
||||
|
||||
|
||||
class Initialize(ActorV3Action):
|
||||
"""
|
||||
Information about machine action.
|
||||
Also returns the id used for the rest of the actions. (Only this one will use actor key)
|
||||
"""
|
||||
|
||||
NAME = 'initialize'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Initialize method expect a json POST with this fields:
|
||||
* type: Actor type. (Currently "managed" or "unmanaged")
|
||||
* version: str -> Actor version
|
||||
* token: str -> Valid Actor Token (if invalid, will return an error)
|
||||
* id: List[dict] -> List of dictionary containing ip and mac:
|
||||
Example:
|
||||
{
|
||||
'type': 'managed,
|
||||
'version': '3.0',
|
||||
'token': 'asbdasdf',
|
||||
'id': [
|
||||
{
|
||||
'mac': 'aa:bb:cc:dd:ee:ff',
|
||||
'ip': 'vvvvvvvv'
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
Will return on field "result" a dictinary with:
|
||||
* token: Optional[str] -> Personal uuid for the service (That, on service, will be used from now onwards). If None, there is no own_token
|
||||
* unique_id: Optional[str] -> If not None, unique id for the service (normally, mac adress of recognized interface)
|
||||
* os: Optional[dict] -> Data returned by os manager for setting up this service.
|
||||
Example:
|
||||
{
|
||||
'token' 'asdfasdfasdffsadfasfd'
|
||||
'unique_id': 'aa:bb:cc:dd:ee:ff'
|
||||
'os': {
|
||||
'action': 'rename',
|
||||
'name': 'new_name'
|
||||
}
|
||||
}
|
||||
On error, will return Empty (None) result, and error field
|
||||
|
||||
Notes:
|
||||
* Unmanaged actors invokes this method JUST ON LOGIN, so the user service has been created already for sure.
|
||||
"""
|
||||
# First, validate token...
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
service: typing.Optional[Service] = None
|
||||
# alias_token will contain a new master token (or same alias if not a token) to allow change on unmanaged machines.
|
||||
# Managed machines will not use this field (will return None)
|
||||
alias_token: typing.Optional[str] = None
|
||||
|
||||
def _initialization_result(
|
||||
token: typing.Optional[str],
|
||||
unique_id: typing.Optional[str],
|
||||
os: typing.Any,
|
||||
master_token: typing.Optional[str],
|
||||
) -> dict[str, typing.Any]:
|
||||
return ActorV3Action.actor_result(
|
||||
{
|
||||
'own_token': token, # Compat with old actor versions, TBR on 5.0
|
||||
'token': token, # New token, will be used from now onwards
|
||||
'master_token': master_token, # Master token, to replace on unmanaged machines
|
||||
'unique_id': unique_id,
|
||||
'os': os,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
token = self._params['token']
|
||||
list_of_ids = get_list_of_ids(self)
|
||||
|
||||
if not list_of_ids:
|
||||
raise exceptions.rest.BlockAccess()
|
||||
|
||||
master_id: typing.Final[str] = list_of_ids[0]
|
||||
|
||||
# First, try to locate an user service providing this token.
|
||||
if self._params['type'] == consts.actor.UNMANAGED:
|
||||
# First, try to locate on alias table
|
||||
|
||||
if ServiceTokenAlias.objects.filter(alias=token, unique_id=master_id).exists():
|
||||
# Retrieve real service from token alias
|
||||
service = ServiceTokenAlias.objects.get(alias=token, unique_id=master_id).service
|
||||
alias_token = token # Store token as possible alias
|
||||
|
||||
# If not found an alias, try to locate on service table
|
||||
# Not on alias token, try to locate on Service table
|
||||
if not service:
|
||||
service = Service.objects.get(token=token)
|
||||
if service.aliases.filter(unique_id=master_id).exists():
|
||||
# If found, get the alias token
|
||||
alias_token = service.aliases.get(unique_id=master_id).alias
|
||||
else:
|
||||
alias_token = CryptoManager.manager().random_string(40) # fix alias with new token
|
||||
service.aliases.create(alias=alias_token, unique_id=master_id)
|
||||
|
||||
# Locate an userService that belongs to this service and which
|
||||
# Build the possible ids and make initial filter to match service
|
||||
dbfilter = UserService.objects.filter(deployed_service__service=service)
|
||||
else:
|
||||
# If not service provided token, use actor tokens
|
||||
if not Server.validate_token(token, server_type=types.servers.ServerType.ACTOR):
|
||||
raise exceptions.rest.BlockAccess()
|
||||
# Build the possible ids and make initial filter to match ANY userservice with provided MAC
|
||||
dbfilter = UserService.objects.all()
|
||||
|
||||
# Valid actor token, now validate access allowed. That is, look for a valid mac from the ones provided.
|
||||
try:
|
||||
# ensure idsLists has upper and lower versions for case sensitive databases
|
||||
# Set full filter
|
||||
dbfilter = dbfilter.filter(
|
||||
unique_id__in=list_of_ids,
|
||||
state__in=State.VALID_STATES,
|
||||
)
|
||||
|
||||
userservice: UserService = next(iter(dbfilter))
|
||||
except Exception as e:
|
||||
logger.info('Not managed host request: %s, %s', self._params, e)
|
||||
return _initialization_result(None, None, None, alias_token)
|
||||
|
||||
# Managed by UDS, get initialization data from osmanager and return it
|
||||
# Set last seen actor version
|
||||
userservice.actor_version = self._params['version']
|
||||
|
||||
# Give the oportunity to change things to the userservice on initialization
|
||||
if userservice.get_instance().actor_initialization(self._params):
|
||||
# Store changes to db
|
||||
userservice.update_data(userservice.get_instance())
|
||||
|
||||
os_data: dict[str, typing.Any] = {}
|
||||
osmanager = userservice.get_osmanager_instance()
|
||||
if osmanager:
|
||||
os_data = osmanager.actor_data(userservice).as_dict()
|
||||
|
||||
return _initialization_result(userservice.uuid, userservice.unique_id, os_data, alias_token)
|
||||
except Service.DoesNotExist:
|
||||
raise exceptions.rest.BlockAccess() from None
|
||||
|
||||
|
||||
class BaseReadyChange(ActorV3Action):
|
||||
"""
|
||||
Records the IP change of actor
|
||||
"""
|
||||
|
||||
NAME = 'notused' # Not really important, this is not a "leaf" class and will not be directly available
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
BaseReady method expect a json POST with this fields:
|
||||
* token: str -> Valid Actor "own_token" (if invalid, will return an error).
|
||||
Currently it is the same as user service uuid, but this could change
|
||||
* secret: Secret for comms_url for actor
|
||||
* ip: ip accesible by uds
|
||||
* port: port of the listener (normally 43910)
|
||||
|
||||
This method will also regenerater the public-private key pair for client, that will be needed for the new ip
|
||||
|
||||
Returns: {
|
||||
key: str -> Generated private key, PEM
|
||||
certificate: str -> Generated public key, PEM
|
||||
password: str -> Password for private key
|
||||
ciphers: str -> Ciphers that server supports (could be empty, so default of python requests will be used)
|
||||
}
|
||||
"""
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
userservice = self.get_userservice()
|
||||
# Stores known IP and notifies it to deployment
|
||||
userservice.log_ip(self._params['ip'])
|
||||
userservice_instance = userservice.get_instance()
|
||||
userservice_instance.set_ip(self._params['ip'])
|
||||
userservice.update_data(userservice_instance)
|
||||
|
||||
# Store communications url also
|
||||
ActorV3Action.set_comms_endpoint(
|
||||
userservice,
|
||||
self._params['ip'],
|
||||
int(self._params['port']),
|
||||
self._params['secret'],
|
||||
)
|
||||
|
||||
if userservice.os_state != State.USABLE:
|
||||
userservice.set_os_state(State.USABLE)
|
||||
# Notify osmanager or readyness if has os manager
|
||||
osmanager = userservice.get_osmanager_instance()
|
||||
|
||||
if osmanager:
|
||||
osmanager.process_ready(userservice)
|
||||
UserServiceManager.manager().notify_ready_from_os_manager(
|
||||
userservice, ''
|
||||
) # Currently, no data is received for os manager
|
||||
|
||||
# Generates a certificate and send it to client.
|
||||
# Password will be removed on a release after 5.0 as it is useful
|
||||
# Currently we have to maintain it for compat with older actors
|
||||
private_key, cert, password = security.create_self_signed_cert(self._params['ip'], with_password=True)
|
||||
# Store certificate with userService
|
||||
userservice.properties['cert'] = cert
|
||||
userservice.properties['priv'] = private_key
|
||||
userservice.properties['priv_passwd'] = password
|
||||
|
||||
return ActorV3Action.actor_cert_result(private_key, cert, password)
|
||||
|
||||
|
||||
class IpChange(BaseReadyChange):
|
||||
"""
|
||||
Processses IP Change.
|
||||
"""
|
||||
|
||||
NAME = 'ipchange'
|
||||
|
||||
|
||||
class Ready(BaseReadyChange):
|
||||
"""
|
||||
Notifies the user service is ready
|
||||
"""
|
||||
|
||||
NAME = 'ready'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Ready method expect a json POST with this fields:
|
||||
* token: str -> Valid Actor "own_token" (if invalid, will return an error).
|
||||
Currently it is the same as user service uuid, but this could change
|
||||
* secret: Secret for comms_url for actor
|
||||
* ip: ip accesible by uds
|
||||
* port: port of the listener (normally 43910)
|
||||
|
||||
Returns: {
|
||||
key: str -> Generated private key, PEM
|
||||
certificate: str -> Generated public key, PEM
|
||||
password: str -> Password for private key
|
||||
ciphers: str -> Ciphers that server supports (could be empty, so default of python requests will be used)
|
||||
}
|
||||
"""
|
||||
result = super().action()
|
||||
|
||||
# Set as "inUse" to false because a ready can only ocurr if an user is not logged in
|
||||
# Note that an assigned dynamic user service that gets "restarted", will be marked as not in use
|
||||
# until it's logged ing again. So, id the system has
|
||||
userservice = self.get_userservice()
|
||||
userservice.set_in_use(False)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class Version(ActorV3Action):
|
||||
"""
|
||||
Notifies the version.
|
||||
Used on possible "customized" actors.
|
||||
"""
|
||||
|
||||
NAME = 'version'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
logger.debug('Version Args: %s, Params: %s', self._args, self._params)
|
||||
userservice = self.get_userservice()
|
||||
userservice.actor_version = self._params['version']
|
||||
userservice.log_ip(self._params['ip'])
|
||||
|
||||
return ActorV3Action.actor_result()
|
||||
|
||||
|
||||
class Login(ActorV3Action):
|
||||
"""
|
||||
Notifies user logged id
|
||||
"""
|
||||
|
||||
NAME = 'login'
|
||||
|
||||
# payload received
|
||||
# {
|
||||
# 'type': actor_type or types.MANAGED,
|
||||
# 'token': token,
|
||||
# 'username': username,
|
||||
# 'session_type': sessionType,
|
||||
# }
|
||||
|
||||
@staticmethod
|
||||
def process_login(userservice: UserService, username: str) -> typing.Optional[osmanagers.OSManager]:
|
||||
osmanager: typing.Optional[osmanagers.OSManager] = userservice.get_osmanager_instance()
|
||||
if not userservice.in_use: # If already logged in, do not add a second login (windows does this i.e.)
|
||||
osmanagers.OSManager.logged_in(userservice, username)
|
||||
return osmanager
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
is_managed = self._params.get('type') != consts.actor.UNMANAGED
|
||||
src = types.connections.ConnectionSource('', '')
|
||||
deadline = max_idle = None
|
||||
session_id = ''
|
||||
|
||||
logger.debug('Login Args: %s, Params: %s', self._args, self._params)
|
||||
|
||||
try:
|
||||
userservice: UserService = self.get_userservice()
|
||||
osmanager = Login.process_login(userservice, self._params.get('username') or '')
|
||||
|
||||
max_idle = osmanager.max_idle() if osmanager else None
|
||||
|
||||
logger.debug('Max idle: %s', max_idle)
|
||||
|
||||
src = userservice.get_connection_source()
|
||||
session_id = userservice.start_session() # creates a session for every login requested
|
||||
|
||||
if osmanager: # For os managed services, let's check if we honor deadline
|
||||
if osmanager.ignore_deadline():
|
||||
deadline = userservice.deployed_service.get_deadline()
|
||||
else:
|
||||
deadline = None
|
||||
else: # For non os manager machines, process deadline as always
|
||||
deadline = userservice.deployed_service.get_deadline()
|
||||
|
||||
except (
|
||||
Exception
|
||||
): # If unamanaged host, lest do a bit more work looking for a service with the provided parameters...
|
||||
if is_managed:
|
||||
raise
|
||||
self.notify_service(action=types.rest.actor.NotifyActionType.LOGIN)
|
||||
|
||||
return ActorV3Action.actor_result(
|
||||
{
|
||||
'ip': src.ip,
|
||||
'hostname': src.hostname,
|
||||
'dead_line': deadline, # Kept for compat, will be removed on 5.x
|
||||
'deadline': deadline,
|
||||
'max_idle': max_idle,
|
||||
'session_id': session_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class Logout(ActorV3Action):
|
||||
"""
|
||||
Notifies user logged out
|
||||
"""
|
||||
|
||||
NAME = 'logout'
|
||||
|
||||
@staticmethod
|
||||
def process_logout(userservice: UserService, username: str, session_id: str) -> None:
|
||||
"""
|
||||
This method is static so can be invoked from elsewhere
|
||||
"""
|
||||
osmanager: typing.Optional[osmanagers.OSManager] = userservice.get_osmanager_instance()
|
||||
|
||||
# Close session
|
||||
# For compat, we have taken '' as "all sessions"
|
||||
userservice.end_session(session_id)
|
||||
|
||||
if userservice.in_use: # If already logged out, do not add a second logout (windows does this i.e.)
|
||||
osmanagers.OSManager.logged_out(userservice, username)
|
||||
# If does not have osmanager, or has osmanager and is removable, release it
|
||||
if not osmanager or osmanager.is_removable_on_logout(userservice):
|
||||
UserServiceManager.manager().release_from_logout(userservice)
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
is_managed = self._params.get('type') != consts.actor.UNMANAGED
|
||||
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
try:
|
||||
userservice: UserService = self.get_userservice() # if not exists, will raise an error
|
||||
Logout.process_logout(
|
||||
userservice,
|
||||
self._params.get('username') or '',
|
||||
self._params.get('session_id') or '',
|
||||
)
|
||||
# If unamanaged host, lets do a bit more work looking for a service with the provided parameters...
|
||||
except Exception:
|
||||
if is_managed:
|
||||
raise
|
||||
self.notify_service(types.rest.actor.NotifyActionType.LOGOUT) # Logout notification
|
||||
# Result is that we have not processed the logout in fact, but notified the service
|
||||
return ActorV3Action.actor_result('notified')
|
||||
|
||||
return ActorV3Action.actor_result('ok')
|
||||
|
||||
|
||||
class Log(ActorV3Action):
|
||||
"""
|
||||
Sends a log from the service
|
||||
"""
|
||||
|
||||
NAME = 'log'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
userservice = self.get_userservice()
|
||||
if userservice.actor_version < '4.0.0':
|
||||
# Adjust loglevel to own, we start on 10000 for OTHER, and received is 0 for OTHER
|
||||
level = types.log.LogLevel.from_int(int(self._params['level']) + 10000)
|
||||
else:
|
||||
level = types.log.LogLevel.from_int(int(self._params['level']))
|
||||
log.log(
|
||||
userservice,
|
||||
level,
|
||||
self._params['message'],
|
||||
types.log.LogSource.ACTOR,
|
||||
)
|
||||
|
||||
return ActorV3Action.actor_result('ok')
|
||||
|
||||
|
||||
class Ticket(ActorV3Action):
|
||||
"""
|
||||
Gets an stored ticket
|
||||
"""
|
||||
|
||||
NAME = 'ticket'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
|
||||
if len(self._args) > 1:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
kind = self._args[0] if len(self._args) == 1 else 'server'
|
||||
|
||||
try:
|
||||
match kind:
|
||||
case 'server':
|
||||
# Server tickets are simple applicaitons with parameters
|
||||
# Enough secure this way (no onwer)
|
||||
try:
|
||||
# Simple check that token exists
|
||||
Server.objects.get(
|
||||
token=self._params['token'], type=types.servers.ServerType.ACTOR
|
||||
) # Not assigned, because only needs check
|
||||
except Server.DoesNotExist:
|
||||
raise exceptions.rest.BlockAccess() from None # If too many blocks...
|
||||
|
||||
return ActorV3Action.actor_result(TicketStore.get(self._params['ticket'], invalidate=True))
|
||||
|
||||
case 'userservice':
|
||||
# Userservice also has owner, to increase security
|
||||
self.get_userservice() # We just want to check that is valid
|
||||
|
||||
return ActorV3Action.actor_result(
|
||||
TicketStore.get(
|
||||
uuid=self._params['ticket'], owner=self._params['token'], invalidate=True
|
||||
)
|
||||
)
|
||||
|
||||
case _:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
except TicketStore.DoesNotExist:
|
||||
return ActorV3Action.actor_result(error='Invalid ticket')
|
||||
|
||||
|
||||
class Unmanaged(ActorV3Action):
|
||||
NAME = 'unmanaged'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
unmanaged method expect a json POST with this fields:
|
||||
* id: List[dict] -> List of dictionary containing ip and mac:
|
||||
* token: str -> Valid Actor "master_token" (if invalid, will return an error).
|
||||
* secret: Secret for comms_url for actor
|
||||
* port: port of the listener (normally 43910)
|
||||
|
||||
This method will also regenerater the public-private key pair for client, that will be needed for the new ip
|
||||
|
||||
Returns: {
|
||||
private_key: str -> Generated private key, PEM
|
||||
server_certificate: str -> Generated public key, PEM
|
||||
}
|
||||
"""
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
|
||||
try:
|
||||
token = self._params['token']
|
||||
if dbservice_alias := ServiceTokenAlias.objects.filter(alias=token).first():
|
||||
# Retrieve real service from token alias
|
||||
dbservice = dbservice_alias.service
|
||||
else:
|
||||
dbservice = Service.objects.get(token=token)
|
||||
service: 'services.Service' = dbservice.get_instance()
|
||||
except Exception:
|
||||
logger.warning('Unmanaged host request: %s', self._params)
|
||||
return ActorV3Action.actor_result(error='Invalid token')
|
||||
|
||||
# ensure idsLists has upper and lower versions for case sensitive databases
|
||||
list_of_ids = get_list_of_ids(self)
|
||||
valid_id: typing.Optional[str] = service.get_valid_id(list_of_ids)
|
||||
|
||||
# Check if there is already an assigned user service
|
||||
# To notify it logout
|
||||
userservice: typing.Optional[UserService]
|
||||
try:
|
||||
userservice = next(
|
||||
iter(
|
||||
UserService.objects.filter(
|
||||
unique_id__in=list_of_ids,
|
||||
state__in=[State.USABLE, State.PREPARING],
|
||||
)
|
||||
)
|
||||
)
|
||||
except StopIteration:
|
||||
userservice = None
|
||||
|
||||
# Try to infer the ip from the valid id (that could be an IP or a MAC)
|
||||
ip: str
|
||||
try:
|
||||
ip = next(
|
||||
x['ip']
|
||||
for x in self._params['id']
|
||||
if valid_id and valid_id.lower() in (x['ip'].lower(), x['mac'].lower())
|
||||
)
|
||||
except StopIteration:
|
||||
ip = self._params['id'][0]['ip'] # Get first IP if no valid ip found
|
||||
|
||||
# Generates a certificate and send it to client (actor).
|
||||
# Password will be removed on a release after 5.0 as it is useful
|
||||
# Currently we have to maintain it for compat with older actors
|
||||
private_key, certificate, password = security.create_self_signed_cert(ip, with_password=True)
|
||||
|
||||
if valid_id:
|
||||
# If id is assigned to an user service, notify "logout" to it
|
||||
if userservice:
|
||||
Logout.process_logout(userservice, 'init', '')
|
||||
else:
|
||||
# If it is not assgined to an user service, notify service
|
||||
service.notify_initialization(valid_id)
|
||||
|
||||
# Store certificate, secret & port with service if service recognized the id
|
||||
service.store_id_info(
|
||||
valid_id,
|
||||
{
|
||||
'cert': certificate,
|
||||
'secret': self._params['secret'],
|
||||
'port': int(self._params['port']),
|
||||
},
|
||||
)
|
||||
|
||||
return ActorV3Action.actor_cert_result(private_key, certificate, password)
|
||||
|
||||
|
||||
class Notify(ActorV3Action):
|
||||
NAME = 'notify'
|
||||
|
||||
def post(self) -> dict[str, typing.Any]:
|
||||
# Raplaces original post (non existent here)
|
||||
raise exceptions.rest.AccessDenied('Access denied')
|
||||
|
||||
def get(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
try:
|
||||
action = types.rest.actor.NotifyActionType(self._params['action'])
|
||||
_token = self._params['token'] # Just to check it exists
|
||||
except Exception as e:
|
||||
# Requested login, logout or whatever
|
||||
raise exceptions.rest.RequestError('Invalid parameters') from e
|
||||
|
||||
try:
|
||||
# Check block manually
|
||||
check_ip_is_blocked(self._request) # pylint: disable=protected-access
|
||||
if action == types.rest.actor.NotifyActionType.LOGIN:
|
||||
Login.action(typing.cast(Login, self))
|
||||
elif action == types.rest.actor.NotifyActionType.LOGOUT:
|
||||
Logout.action(typing.cast(Logout, self))
|
||||
elif action == types.rest.actor.NotifyActionType.DATA:
|
||||
self.notify_service(action)
|
||||
|
||||
return ActorV3Action.actor_result('ok')
|
||||
except UserService.DoesNotExist:
|
||||
# For blocking attacks
|
||||
increase_failed_ip_count(self._request) # pylint: disable=protected-access
|
||||
|
||||
raise exceptions.rest.AccessDenied('Access denied')
|
||||
@@ -1,348 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2024 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 collections.abc
|
||||
import dataclasses
|
||||
import itertools
|
||||
import logging
|
||||
import re
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
|
||||
from uds.core import auths, consts, exceptions, types, ui
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.models import MFA, Authenticator, Network, Tag
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
from .users_groups import Groups, Users
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
|
||||
from uds.core.module import Module
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AuthenticatorTypeInfo(types.rest.ExtraTypeInfo):
|
||||
search_users_supported: bool
|
||||
search_groups_supported: bool
|
||||
needs_password: bool
|
||||
label_username: str
|
||||
label_groupname: str
|
||||
label_password: str
|
||||
create_users_supported: bool
|
||||
is_external: bool
|
||||
mfa_data_enabled: bool
|
||||
mfa_supported: bool
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AuthenticatorItem(types.rest.ManagedObjectItem[Authenticator]):
|
||||
numeric_id: int
|
||||
id: str
|
||||
name: str
|
||||
priority: int
|
||||
|
||||
tags: list[str]
|
||||
comments: str
|
||||
net_filtering: str
|
||||
networks: list[str]
|
||||
state: str
|
||||
mfa_id: str
|
||||
small_name: str
|
||||
users_count: int
|
||||
permission: int
|
||||
|
||||
type_info: types.rest.TypeInfo | None
|
||||
|
||||
|
||||
# Enclosed methods under /auth path
|
||||
class Authenticators(ModelHandler[AuthenticatorItem]):
|
||||
ITEM_TYPE = AuthenticatorItem
|
||||
|
||||
MODEL = Authenticator
|
||||
# Custom get method "search" that requires authenticator id
|
||||
CUSTOM_METHODS = [types.rest.ModelCustomMethod('search', True)]
|
||||
DETAIL = {'users': Users, 'groups': Groups}
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Authenticators'))
|
||||
.numeric_column(name='numeric_id', title=_('Id'), visible=True, width='1rem')
|
||||
.icon(name='name', title=_('Name'), visible=True)
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='priority', title=_('Priority'), width='8rem')
|
||||
.text_column(name='small_name', title=_('Label'))
|
||||
.numeric_column(name='users_count', title=_('Users'), width='6rem')
|
||||
.text_column(name='mfa_name', title=_('MFA'))
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.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,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[auths.Authenticator]]:
|
||||
return auths.factory().providers().values()
|
||||
|
||||
@classmethod
|
||||
def extra_type_info(
|
||||
cls: type[typing.Self], type_: type['Module']
|
||||
) -> typing.Optional[AuthenticatorTypeInfo]:
|
||||
if issubclass(type_, auths.Authenticator):
|
||||
return AuthenticatorTypeInfo(
|
||||
search_users_supported=type_.search_users != auths.Authenticator.search_users,
|
||||
search_groups_supported=type_.search_groups != auths.Authenticator.search_groups,
|
||||
needs_password=type_.needs_password,
|
||||
label_username=_(type_.label_username),
|
||||
label_groupname=_(type_.label_groupname),
|
||||
label_password=_(type_.label_password),
|
||||
create_users_supported=type_.create_user != auths.Authenticator.create_user,
|
||||
is_external=type_.external_source,
|
||||
mfa_data_enabled=type_.mfa_data_enabled,
|
||||
mfa_supported=type_.provides_mfa_identifier(),
|
||||
)
|
||||
# Not of my type
|
||||
return None
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
try:
|
||||
auth_type = auths.factory().lookup(for_type)
|
||||
if auth_type:
|
||||
# Create a new instance of the authenticator to access to its GUI
|
||||
with Environment.temporary_environment() as env:
|
||||
# If supports mfa, add MFA provider selector field
|
||||
auth_instance = auth_type(env, None)
|
||||
gui = (
|
||||
(
|
||||
ui_utils.GuiBuilder()
|
||||
.set_order(100)
|
||||
.add_stock_field(types.rest.stock.StockField.PRIORITY)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.LABEL)
|
||||
.add_stock_field(types.rest.stock.StockField.NETWORKS)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
)
|
||||
.add_fields(auth_instance.gui_description())
|
||||
.add_choice(
|
||||
name='state',
|
||||
default=consts.auth.VISIBLE,
|
||||
choices=[
|
||||
ui.gui.choice_item(consts.auth.VISIBLE, _('Visible')),
|
||||
ui.gui.choice_item(consts.auth.HIDDEN, _('Hidden')),
|
||||
ui.gui.choice_item(consts.auth.DISABLED, _('Disabled')),
|
||||
],
|
||||
label=gettext('Access'),
|
||||
)
|
||||
)
|
||||
|
||||
if auth_type.provides_mfa_identifier():
|
||||
gui.add_choice(
|
||||
name='mfa_id',
|
||||
label=gettext('MFA Provider'),
|
||||
choices=[ui.gui.choice_item('', str(_('None')))]
|
||||
+ ui.gui.sorted_choices(
|
||||
[ui.gui.choice_item(v.uuid, v.name) for v in MFA.objects.all()]
|
||||
),
|
||||
)
|
||||
|
||||
return gui.build()
|
||||
|
||||
raise Exception() # Not found
|
||||
except Exception as e:
|
||||
logger.info('Authenticator type not found: %s', e)
|
||||
raise exceptions.rest.NotFound('Authenticator type not found') from e
|
||||
|
||||
def get_item(self, item: 'models.Model') -> AuthenticatorItem:
|
||||
item = ensure.is_instance(item, Authenticator)
|
||||
|
||||
return AuthenticatorItem(
|
||||
numeric_id=item.id,
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
priority=item.priority,
|
||||
tags=[tag.tag for tag in typing.cast(collections.abc.Iterable[Tag], item.tags.all())],
|
||||
comments=item.comments,
|
||||
net_filtering=item.net_filtering,
|
||||
networks=[n.uuid for n in item.networks.all()],
|
||||
state=item.state,
|
||||
mfa_id=item.mfa.uuid if item.mfa else '',
|
||||
small_name=item.small_name,
|
||||
users_count=item.users.count(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
item=item,
|
||||
type_info=type(self).as_typeinfo(item.get_type()),
|
||||
)
|
||||
|
||||
def apply_sort(self, qs: 'QuerySet[typing.Any]') -> 'list[typing.Any] | QuerySet[typing.Any]':
|
||||
if field_info := self.get_sort_field_info('users_count'):
|
||||
field_name, is_descending = field_info
|
||||
order_by_field = f"-{field_name}" if is_descending else field_name
|
||||
return qs.annotate(users_count=models.Count('users')).order_by(order_by_field)
|
||||
|
||||
if field_info := self.get_sort_field_info('type_name'):
|
||||
_, is_descending = field_info
|
||||
order_by_field = f'-data_type' if is_descending else 'data_type'
|
||||
return qs.order_by(order_by_field)
|
||||
|
||||
if field_info := self.get_sort_field_info('numeric_id'):
|
||||
_, is_descending = field_info
|
||||
order_by_field = f'-pk' if is_descending else 'pk'
|
||||
return qs.order_by(order_by_field)
|
||||
|
||||
if field_info := self.get_sort_field_info('mfa_name'):
|
||||
_, is_descending = field_info
|
||||
order_by_field = f'-mfa__name' if is_descending else 'mfa__name'
|
||||
return qs.order_by(order_by_field)
|
||||
|
||||
return super().apply_sort(qs)
|
||||
|
||||
def post_save(self, item: 'models.Model') -> None:
|
||||
item = ensure.is_instance(item, Authenticator)
|
||||
try:
|
||||
networks = self._params['networks']
|
||||
except Exception: # No networks passed in, this is ok
|
||||
logger.debug('No networks')
|
||||
return
|
||||
if networks is None: # None is not provided, empty list is ok and means no networks
|
||||
return
|
||||
logger.debug('Networks: %s', networks)
|
||||
item.networks.set(Network.objects.filter(uuid__in=networks))
|
||||
|
||||
# Custom "search" method
|
||||
def search(self, item: 'models.Model') -> list[types.auth.SearchResultItem.ItemDict]:
|
||||
"""
|
||||
API:
|
||||
Search for users or groups in this authenticator
|
||||
"""
|
||||
item = ensure.is_instance(item, Authenticator)
|
||||
self.check_access(item, types.permissions.PermissionType.READ)
|
||||
try:
|
||||
type_ = self._params['type']
|
||||
if type_ not in ('user', 'group'):
|
||||
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
|
||||
|
||||
term = self._params['term']
|
||||
|
||||
limit = int(self._params.get('limit', '50'))
|
||||
|
||||
auth = item.get_instance()
|
||||
|
||||
# Cast to Any because we want to compare with the default method or if it's overriden
|
||||
# Cast is neccesary to avoid mypy errors, for example
|
||||
search_supported = (
|
||||
type_ == 'user'
|
||||
and (
|
||||
typing.cast(typing.Any, auth.search_users)
|
||||
!= typing.cast(typing.Any, auths.Authenticator.search_users)
|
||||
)
|
||||
or (
|
||||
typing.cast(typing.Any, auth.search_groups)
|
||||
!= typing.cast(typing.Any, auths.Authenticator.search_groups)
|
||||
)
|
||||
)
|
||||
if search_supported is False:
|
||||
raise exceptions.rest.NotSupportedError(_('Search not supported'))
|
||||
|
||||
if type_ == 'user':
|
||||
iterable = auth.search_users(term)
|
||||
else:
|
||||
iterable = auth.search_groups(term)
|
||||
|
||||
return [i.as_dict() for i in itertools.islice(iterable, limit)]
|
||||
except Exception as e:
|
||||
logger.exception('Too many results: %s', e)
|
||||
return [
|
||||
types.auth.SearchResultItem(id=_('Too many results...'), name=_('Refine your query')).as_dict()
|
||||
]
|
||||
# self.invalidResponseException('{}'.format(e))
|
||||
|
||||
def test(self, type_: str) -> typing.Any:
|
||||
auth_type = auths.factory().lookup(type_)
|
||||
if not auth_type:
|
||||
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
|
||||
|
||||
dct = self._params.copy()
|
||||
dct['_request'] = self._request
|
||||
with Environment.temporary_environment() as env:
|
||||
res = auth_type.test(env, dct)
|
||||
if res.success:
|
||||
return self.success()
|
||||
return res.error
|
||||
|
||||
def pre_save(
|
||||
self, fields: dict[str, typing.Any]
|
||||
) -> None: # pylint: disable=too-many-branches,too-many-statements
|
||||
logger.debug(self._params)
|
||||
if fields.get('mfa_id'):
|
||||
try:
|
||||
mfa = MFA.objects.get(uuid=process_uuid(fields['mfa_id']))
|
||||
fields['mfa_id'] = mfa.id
|
||||
except MFA.DoesNotExist:
|
||||
pass # will set field to null
|
||||
else:
|
||||
fields['mfa_id'] = None
|
||||
|
||||
# If label has spaces, replace them with underscores
|
||||
fields['small_name'] = fields['small_name'].strip().replace(' ', '_')
|
||||
# And ensure small_name chars are valid [a-zA-Z0-9:-]+
|
||||
if fields['small_name'] and not re.match(r'^[a-zA-Z0-9:.-]+$', fields['small_name']):
|
||||
raise exceptions.rest.RequestError(_('Label must contain only letters, numbers, or symbols: - : .'))
|
||||
|
||||
def delete_item(self, item: 'models.Model') -> None:
|
||||
# For every user, remove assigned services (mark them for removal)
|
||||
item = ensure.is_instance(item, Authenticator)
|
||||
|
||||
for user in item.users.all():
|
||||
for userservice in user.userServices.all():
|
||||
userservice.user = None
|
||||
userservice.remove_or_cancel()
|
||||
|
||||
item.delete()
|
||||
@@ -1,66 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
import typing
|
||||
|
||||
from django.core.cache import caches
|
||||
|
||||
from uds.core import exceptions, consts
|
||||
from uds.core.util.cache import Cache as UCache
|
||||
from uds.REST import Handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Enclosed methods under /cache path
|
||||
class Cache(Handler):
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
"""
|
||||
Processes get method. Basically, clears & purges the cache, no matter what params
|
||||
"""
|
||||
logger.debug('Params: %s', self._params)
|
||||
if not self._args:
|
||||
return {}
|
||||
|
||||
if len(self._args) != 1:
|
||||
raise exceptions.rest.RequestError('Invalid Request')
|
||||
|
||||
UCache.purge()
|
||||
for i in ('default', 'memory'):
|
||||
try:
|
||||
caches[i].clear()
|
||||
except Exception:
|
||||
pass # Ignore non existing cache
|
||||
return 'done'
|
||||
@@ -1,184 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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.db import IntegrityError, models
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core import exceptions, types
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid, sql_now
|
||||
from uds.models.calendar import Calendar
|
||||
from uds.models.calendar_rule import CalendarRule, FrequencyInfo
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CalendarRuleItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
comments: str
|
||||
start: datetime.datetime
|
||||
end: datetime.datetime | None
|
||||
frequency: str
|
||||
interval: int
|
||||
duration: int
|
||||
duration_unit: str
|
||||
permission: int
|
||||
|
||||
|
||||
class CalendarRules(DetailHandler[CalendarRuleItem]): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Detail handler for Services, whose parent is a Provider
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def rule_as_dict(item: CalendarRule, perm: int) -> CalendarRuleItem:
|
||||
"""
|
||||
Convert a calrule db item to a dict for a rest response
|
||||
:param item: Rule item (db)
|
||||
:param perm: Permission of the object
|
||||
"""
|
||||
return CalendarRuleItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
comments=item.comments,
|
||||
start=item.start,
|
||||
end=(
|
||||
timezone.make_aware(datetime.datetime.combine(item.end, datetime.time.max))
|
||||
if item.end
|
||||
else None
|
||||
),
|
||||
frequency=item.frequency,
|
||||
interval=item.interval,
|
||||
duration=item.duration,
|
||||
duration_unit=item.duration_unit,
|
||||
permission=perm,
|
||||
)
|
||||
|
||||
def get_item_position(self, parent: 'models.Model', item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
return self.calc_item_position(item_uuid, parent.rules.all())
|
||||
|
||||
def get_items(self, parent: 'models.Model') -> types.rest.ItemsResult[CalendarRuleItem]:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
# Check what kind of access do we have to parent provider
|
||||
perm = permissions.effective_permissions(self._user, parent)
|
||||
return [CalendarRules.rule_as_dict(k, perm) for k in self.filter_odata_queryset(parent.rules.all())]
|
||||
|
||||
def get_item(self, parent: 'models.Model', item: str) -> CalendarRuleItem:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
# Check what kind of access do we have to parent provider
|
||||
return CalendarRules.rule_as_dict(
|
||||
parent.rules.get(uuid=process_uuid(item)), permissions.effective_permissions(self._user, parent)
|
||||
)
|
||||
|
||||
def get_table(self, parent: 'models.Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Rules of {0}').format(parent.name))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.datetime_column(name='start', title=_('Start'))
|
||||
.date(name='end', title=_('End'))
|
||||
.dict_column(name='frequency', title=_('Frequency'), dct=FrequencyInfo.literals_dict())
|
||||
.numeric_column(name='interval', title=_('Interval'))
|
||||
.numeric_column(name='duration', title=_('Duration'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'models.Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
|
||||
# Extract item db fields
|
||||
# We need this fields for all
|
||||
logger.debug('Saving rule %s / %s', parent, item)
|
||||
fields = self.fields_from_params(
|
||||
[
|
||||
'name',
|
||||
'comments',
|
||||
'frequency',
|
||||
'start',
|
||||
'end',
|
||||
'interval',
|
||||
'duration',
|
||||
'duration_unit',
|
||||
]
|
||||
)
|
||||
|
||||
if int(fields['interval']) < 1:
|
||||
raise exceptions.rest.RequestError('Repeat must be greater than zero')
|
||||
|
||||
# Convert timestamps to datetimes
|
||||
fields['start'] = timezone.make_aware(datetime.datetime.fromtimestamp(fields['start']))
|
||||
if fields['end'] is not None:
|
||||
fields['end'] = timezone.make_aware(datetime.datetime.fromtimestamp(fields['end']))
|
||||
|
||||
calendar_rule: CalendarRule
|
||||
try:
|
||||
if item is None: # Create new
|
||||
calendar_rule = parent.rules.create(**fields)
|
||||
else:
|
||||
calendar_rule = parent.rules.get(uuid=process_uuid(item))
|
||||
calendar_rule.__dict__.update(fields)
|
||||
calendar_rule.save()
|
||||
return {'id': calendar_rule.uuid}
|
||||
except CalendarRule.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Calendar rule not found: {}').format(item)) from None
|
||||
except IntegrityError as e: # Duplicate key probably
|
||||
raise exceptions.rest.RequestError(_('Element already exists (duplicate key error)')) from e
|
||||
except Exception as e:
|
||||
logger.exception('Saving calendar')
|
||||
raise exceptions.rest.RequestError(f'incorrect invocation to PUT: {e}') from e
|
||||
|
||||
def delete_item(self, parent: 'models.Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
logger.debug('Deleting rule %s from %s', item, parent)
|
||||
try:
|
||||
calendar_rule = parent.rules.get(uuid=process_uuid(item))
|
||||
calendar_rule.calendar.modified = sql_now()
|
||||
calendar_rule.calendar.save()
|
||||
calendar_rule.delete()
|
||||
except CalendarRule.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Calendar rule not found: {}').format(item)) from None
|
||||
except Exception as e:
|
||||
logger.error('Error deleting calendar rule %s from %s', item, parent)
|
||||
raise exceptions.rest.RequestError(f'Error deleting calendar rule: {e}') from e
|
||||
@@ -1,113 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from uds.core import types
|
||||
from uds.models import Calendar
|
||||
from uds.core.util import permissions, ensure, ui as ui_utils
|
||||
|
||||
from uds.REST.model import ModelHandler
|
||||
from .calendarrules import CalendarRules
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CalendarItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
comments: str
|
||||
modified: datetime.datetime
|
||||
number_rules: int
|
||||
number_access: int
|
||||
number_actions: int
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
|
||||
class Calendars(ModelHandler[CalendarItem]):
|
||||
"""
|
||||
Processes REST requests about calendars
|
||||
"""
|
||||
|
||||
MODEL = Calendar
|
||||
DETAIL = {'rules': CalendarRules}
|
||||
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Calendars'))
|
||||
.text_column(name='name', title=_('Name'), visible=True)
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.datetime_column(name='modified', title=_('Modified'))
|
||||
.numeric_column(name='number_rules', title=_('Rules'), width='5rem')
|
||||
.numeric_column(name='number_access', title=_('Pools with Accesses'), width='5rem')
|
||||
.numeric_column(name='number_actions', title=_('Pools with Actions'), 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.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_item(self, item: 'models.Model') -> CalendarItem:
|
||||
item = ensure.is_instance(item, Calendar)
|
||||
return CalendarItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
modified=item.modified,
|
||||
number_rules=item.rules.count(),
|
||||
number_access=item.calendaraccess_set.all().values('service_pool').distinct().count(),
|
||||
number_actions=item.calendaraction_set.all().values('service_pool').distinct().count(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
def get_gui(self, for_type: str) -> list[typing.Any]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.build()
|
||||
)
|
||||
@@ -1,297 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-2021 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 logging
|
||||
import typing
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds import models
|
||||
from uds.core import consts, exceptions, types
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core.managers.userservice import UserServiceManager
|
||||
from uds.core.exceptions.services import ServiceNotReadyError
|
||||
from uds.core.types.log import LogLevel, LogSource
|
||||
from uds.core.util.config import GlobalConfig
|
||||
from uds.core.util.model import sql_stamp_seconds
|
||||
from uds.core.util.rest.tools import match_args
|
||||
from uds.models import TicketStore, User
|
||||
from uds.REST import Handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CLIENT_VERSION: typing.Final[str] = consts.system.VERSION
|
||||
LOG_ENABLED_DURATION: typing.Final[int] = 2 * 60 * 60 * 24 # 2 days
|
||||
|
||||
|
||||
# Enclosed methods under /client path
|
||||
class Client(Handler):
|
||||
"""
|
||||
Processes Client requests
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
|
||||
@staticmethod
|
||||
def result(
|
||||
result: typing.Any = None,
|
||||
error: typing.Optional[typing.Union[str, int]] = None,
|
||||
error_code: int = 0,
|
||||
is_retrayable: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Helper method to create a "result" set for actor response
|
||||
|
||||
Args:
|
||||
result: Result value to return (can be None, in which case it is converted to empty string '')
|
||||
error: If present, This response represents an error. Result will contain an "Explanation" and error contains the error code
|
||||
error_code: Code of the error to return, if error is not None
|
||||
retryable: If True, this operation can (and must) be retried
|
||||
|
||||
Returns:
|
||||
A dictionary, suitable for REST response
|
||||
"""
|
||||
result = result if result is not None else ''
|
||||
res = {'result': result}
|
||||
if error:
|
||||
if isinstance(error, int):
|
||||
error = types.errors.Error.from_int(error).message
|
||||
# error = str(error) # Ensures error is an string
|
||||
if error_code != 0:
|
||||
# Reformat error so it is better understood by users
|
||||
# error += ' (code {0:04X})'.format(errorCode)
|
||||
error = (
|
||||
_('Your service is being created. Please, wait while we complete it')
|
||||
+ f' ({int(error_code)*25}%)'
|
||||
)
|
||||
|
||||
res['error'] = error
|
||||
# is_retrayable is new key, but we keep retryable for compatibility
|
||||
res['is_retryable'] = res['retryable'] = '1' if is_retrayable else '0'
|
||||
|
||||
logger.debug('Client Result: %s', res)
|
||||
|
||||
return res
|
||||
|
||||
def test(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Executes and returns the test
|
||||
"""
|
||||
return Client.result(_('Correct'))
|
||||
|
||||
def process(self, ticket: str, scrambler: str) -> dict[str, typing.Any]:
|
||||
info: typing.Optional[types.services.UserServiceInfo] = None
|
||||
hostname = self._params.get('hostname', '') # Or if hostname is not included...
|
||||
version = self._params.get('version', '0.0.0')
|
||||
src_ip = self._request.ip
|
||||
|
||||
if version < consts.system.VERSION_REQUIRED_CLIENT:
|
||||
return Client.result(error='Client version not supported.\n Please, upgrade it.')
|
||||
|
||||
# Ip is optional,
|
||||
if GlobalConfig.HONOR_CLIENT_IP_NOTIFY.as_bool() is True:
|
||||
src_ip = self._params.get('ip', src_ip)
|
||||
|
||||
logger.debug(
|
||||
'Got Ticket: %s, scrambled: %s, Hostname: %s, Ip: %s',
|
||||
ticket,
|
||||
scrambler,
|
||||
hostname,
|
||||
src_ip,
|
||||
)
|
||||
|
||||
try:
|
||||
data: dict[str, typing.Any] = TicketStore.get(ticket)
|
||||
except TicketStore.DoesNotExist:
|
||||
return Client.result(error=types.errors.Error.ACCESS_DENIED)
|
||||
|
||||
self._request.user = User.objects.get(uuid=data['user'])
|
||||
|
||||
try:
|
||||
logger.debug(data)
|
||||
info = UserServiceManager.manager().get_user_service_info(
|
||||
self._request.user,
|
||||
self._request.os,
|
||||
self._request.ip,
|
||||
data['service'],
|
||||
data['transport'],
|
||||
client_hostname=hostname,
|
||||
)
|
||||
logger.debug('Res: %s', info)
|
||||
password = CryptoManager.manager().symmetric_decrypt(data['password'], scrambler)
|
||||
|
||||
# userService.setConnectionSource(srcIp, hostname) # Store where we are accessing from so we can notify Service
|
||||
if not info.ip:
|
||||
raise ServiceNotReadyError()
|
||||
|
||||
transport_script = info.transport.get_instance().encoded_transport_script(
|
||||
info.userservice,
|
||||
info.transport,
|
||||
info.ip,
|
||||
self._request.os,
|
||||
self._request.user,
|
||||
password,
|
||||
self._request,
|
||||
)
|
||||
|
||||
logger.debug('Script: %s', transport_script)
|
||||
|
||||
# Log is enabled if user has log_enabled property set to
|
||||
try:
|
||||
log_enabled_since_limit = sql_stamp_seconds() - LOG_ENABLED_DURATION
|
||||
log_enabled_since = self._request.user.properties.get('client_logging', log_enabled_since_limit)
|
||||
is_logging_enabled = False if log_enabled_since <= log_enabled_since_limit else True
|
||||
except Exception:
|
||||
is_logging_enabled = False
|
||||
log: dict[str, 'str|None'] = {
|
||||
'level': 'DEBUG',
|
||||
'ticket': None,
|
||||
}
|
||||
|
||||
if is_logging_enabled:
|
||||
log['ticket'] = TicketStore.create(
|
||||
{
|
||||
'user': self._request.user.uuid,
|
||||
'userservice': info.userservice.uuid,
|
||||
'type': 'log',
|
||||
},
|
||||
# Long enough for a looong time, will be cleaned on first access
|
||||
# Or 24 hours after creation, whatever happens first
|
||||
validity=60 * 60 * 24,
|
||||
)
|
||||
|
||||
return Client.result(
|
||||
result={
|
||||
'script': transport_script.script,
|
||||
'type': transport_script.script_type,
|
||||
'signature': transport_script.signature_b64, # It is already on base64
|
||||
'params': transport_script.encoded_parameters,
|
||||
'log': log,
|
||||
}
|
||||
)
|
||||
except ServiceNotReadyError as e:
|
||||
# Refresh ticket and make this retrayable
|
||||
TicketStore.revalidate(ticket, 20) # Retry will be in at most 5 seconds, so 20 is fine :)
|
||||
return Client.result(
|
||||
error=types.errors.Error.SERVICE_IN_PREPARATION, error_code=e.code, is_retrayable=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Exception")
|
||||
return Client.result(error=str(e))
|
||||
|
||||
finally:
|
||||
# ensures that we mark the service as accessed by client
|
||||
# so web interface can show can react to this
|
||||
if info and info.userservice:
|
||||
info.userservice.properties['accessed_by_client'] = True
|
||||
|
||||
def post(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Processes put requests
|
||||
|
||||
Currently, only "upload logs"
|
||||
"""
|
||||
logger.debug('Client args for POST: %s', self._args)
|
||||
try:
|
||||
ticket, command = self._args[:2]
|
||||
try:
|
||||
data: dict[str, typing.Any] = TicketStore.get(ticket)
|
||||
except TicketStore.DoesNotExist:
|
||||
return Client.result(error=types.errors.Error.ACCESS_DENIED)
|
||||
|
||||
self._request.user = User.objects.get(uuid=data['user'])
|
||||
|
||||
try:
|
||||
userservice = models.UserService.objects.get(uuid=data['userservice'])
|
||||
except models.UserService.DoesNotExist:
|
||||
return Client.result(error='Service not found')
|
||||
|
||||
match command:
|
||||
case 'log':
|
||||
if data.get('type') != 'log':
|
||||
return Client.result(error='Invalid command')
|
||||
|
||||
log: str = self._params.get('log', '')
|
||||
# Right now, log to logger, but will be stored with user logs
|
||||
logger.info('Client %s: %s', self._request.user.pretty_name, userservice.service_pool.name)
|
||||
for line in log.split('\n'):
|
||||
# Firt word is level
|
||||
try:
|
||||
level, message = line.split(' ', 1)
|
||||
userservice.log(message, LogLevel.from_str(level), LogSource.CLIENT)
|
||||
logger.info('Client %s: %s', self._request.user.pretty_name, message)
|
||||
except Exception:
|
||||
# If something goes wrong, log it as debug
|
||||
pass
|
||||
case _:
|
||||
return Client.result(error='Invalid command')
|
||||
|
||||
except Exception as e:
|
||||
return Client.result(error=str(e))
|
||||
|
||||
return Client.result(result='Ok')
|
||||
|
||||
def get(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Processes get requests
|
||||
"""
|
||||
logger.debug('Client args for GET: %s', self._args)
|
||||
|
||||
def _error() -> None:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
def _noargs() -> dict[str, typing.Any]:
|
||||
return Client.result(
|
||||
{
|
||||
'availableVersion': CLIENT_VERSION, # Compat with old clients, TB removed soon...
|
||||
'available_version': CLIENT_VERSION,
|
||||
'requiredVersion': consts.system.VERSION_REQUIRED_CLIENT, # Compat with old clients, TB removed soon...
|
||||
'required_version': consts.system.VERSION_REQUIRED_CLIENT,
|
||||
'downloadUrl': self._request.build_absolute_uri(
|
||||
reverse('page.client-download')
|
||||
), # Compat with old clients, TB removed soon...
|
||||
'client_link': self._request.build_absolute_uri(reverse('page.client-download')),
|
||||
}
|
||||
)
|
||||
|
||||
return match_args(
|
||||
self._args,
|
||||
_error, # In case of error, raises RequestError
|
||||
((), _noargs), # No args, return version
|
||||
(('test',), self.test), # Test request, returns "Correct"
|
||||
(
|
||||
(
|
||||
'<ticket>',
|
||||
'<crambler>',
|
||||
),
|
||||
self.process,
|
||||
), # Process request, needs ticket and scrambler
|
||||
)
|
||||
@@ -1,76 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 typing
|
||||
import logging
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core.util.config import Config as CfgConfig
|
||||
from uds.REST import Handler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Enclosed methods under /config path
|
||||
class Config(Handler):
|
||||
"""
|
||||
API:
|
||||
Get or update UDS configuration
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
return self.filter_odata_data(CfgConfig.get_config_values(self.is_admin()))
|
||||
|
||||
def put(self) -> typing.Any:
|
||||
for section, section_dict in typing.cast(dict[str, dict[str, dict[str, str]]], self._params).items():
|
||||
for key, vals in section_dict.items():
|
||||
config = CfgConfig.update(CfgConfig.SectionType.from_str(section), key, vals['value'])
|
||||
if config is not None:
|
||||
logger.info(
|
||||
'Updating config value %s.%s to %s by %s',
|
||||
section,
|
||||
key,
|
||||
vals['value'] if not config.is_password else '********',
|
||||
self._user.name,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
'Non existing config value %s.%s to %s by %s',
|
||||
section,
|
||||
key,
|
||||
vals['value'],
|
||||
self._user.name,
|
||||
)
|
||||
return 'done'
|
||||
@@ -1,190 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2015-2019 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
import typing
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core.managers.userservice import UserServiceManager
|
||||
from uds.core.exceptions.services import ServiceNotReadyError
|
||||
from uds.core.util.rest.tools import match_args
|
||||
from uds.REST import Handler
|
||||
from uds.web.util import services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Enclosed methods under /connection path
|
||||
class Connection(Handler):
|
||||
"""
|
||||
Processes actor requests
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.USER
|
||||
|
||||
@staticmethod
|
||||
def result(
|
||||
result: typing.Any = None,
|
||||
error: typing.Optional[typing.Union[str, int]] = None,
|
||||
error_code: int = 0,
|
||||
is_retrayable: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Helper method to create a "result" set for connection response
|
||||
:param result: Result value to return (can be None, in which case it is converted to empty string '')
|
||||
:param error: If present, This response represents an error. Result will contain an "Explanation" and error contains the error code
|
||||
:return: A dictionary, suitable for response to Caller
|
||||
"""
|
||||
result = result if result is not None else ''
|
||||
res = {'result': result, 'date': timezone.localtime()}
|
||||
if error:
|
||||
if isinstance(error, int):
|
||||
error = types.errors.Error.from_int(error).message
|
||||
error = str(error) # Ensure error is an string
|
||||
if error_code != 0:
|
||||
error += f' (code {error_code:04X})'
|
||||
res['error'] = error
|
||||
|
||||
res['retryable'] = '1' if is_retrayable else '0'
|
||||
|
||||
return res
|
||||
|
||||
def service_list(self) -> dict[str, typing.Any]:
|
||||
# We look for services for this authenticator groups. User is logged in in just 1 authenticator, so his groups must coincide with those assigned to ds
|
||||
# Ensure user is present on request, used by web views methods
|
||||
self._request.user = self._user
|
||||
|
||||
return Connection.result(result=self.filter_odata_data(services.get_services_info_dict(self._request)))
|
||||
|
||||
def connection(self, id_service: str, id_transport: str, skip: str = '') -> dict[str, typing.Any]:
|
||||
skip_check = skip in ('doNotCheck', 'do_not_check', 'no_check', 'nocheck', 'skip_check')
|
||||
try:
|
||||
info = UserServiceManager.manager().get_user_service_info( # pylint: disable=unused-variable
|
||||
self._user,
|
||||
self._request.os,
|
||||
self._request.ip,
|
||||
id_service,
|
||||
id_transport,
|
||||
not skip_check,
|
||||
)
|
||||
connection_info = {
|
||||
'username': '',
|
||||
'password': '',
|
||||
'domain': '',
|
||||
'protocol': 'unknown',
|
||||
'ip': info.ip or '',
|
||||
}
|
||||
if info.ip: # only will be available id doNotCheck is False
|
||||
connection_info.update(
|
||||
info.transport.get_instance()
|
||||
.get_connection_info(info.userservice, self._user, 'UNKNOWN')
|
||||
.as_dict()
|
||||
)
|
||||
return Connection.result(result=connection_info)
|
||||
except ServiceNotReadyError as e:
|
||||
# Refresh ticket and make this retrayable
|
||||
return Connection.result(
|
||||
error=types.errors.Error.SERVICE_IN_PREPARATION, error_code=e.code, is_retrayable=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Exception")
|
||||
return Connection.result(error=str(e))
|
||||
|
||||
def script(self, id_service: str, id_transport: str, scrambler: str, hostname: str) -> dict[str, typing.Any]:
|
||||
try:
|
||||
info = UserServiceManager.manager().get_user_service_info(
|
||||
self._user, self._request.os, self._request.ip, id_service, id_transport
|
||||
)
|
||||
password = CryptoManager.manager().symmetric_decrypt(self.recover_value('password'), scrambler)
|
||||
|
||||
info.userservice.set_connection_source(
|
||||
types.connections.ConnectionSource(self._request.ip, hostname)
|
||||
) # Store where we are accessing from so we can notify Service
|
||||
|
||||
if not info.ip:
|
||||
raise ServiceNotReadyError()
|
||||
|
||||
transport_script = info.transport.get_instance().encoded_transport_script(
|
||||
info.userservice,
|
||||
info.transport,
|
||||
info.ip,
|
||||
self._request.os,
|
||||
self._user,
|
||||
password,
|
||||
self._request,
|
||||
)
|
||||
|
||||
return Connection.result(result=transport_script)
|
||||
except ServiceNotReadyError as e:
|
||||
# Refresh ticket and make this retrayable
|
||||
return Connection.result(
|
||||
error=types.errors.Error.SERVICE_IN_PREPARATION, error_code=e.code, is_retrayable=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Exception")
|
||||
return Connection.result(error=str(e))
|
||||
|
||||
def get_ticket_content(self, ticketId: str) -> dict[str, typing.Any]: # pylint: disable=unused-argument
|
||||
return {}
|
||||
|
||||
def get_uds_link(self, id_service: str, id_transport: str) -> dict[str, typing.Any]:
|
||||
# Returns the UDS link for the user & transport
|
||||
self._request.user = self._user
|
||||
setattr(self._request, '_cryptedpass', self.session['REST']['password'])
|
||||
setattr(self._request, '_scrambler', self._request.META['HTTP_SCRAMBLER'])
|
||||
link_info = services.enable_service(self._request, service_id=id_service, transport_id=id_transport)
|
||||
if link_info['error']:
|
||||
return Connection.result(error=link_info['error'])
|
||||
return Connection.result(result=link_info['url'])
|
||||
|
||||
def get(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Processes get requests
|
||||
"""
|
||||
logger.debug('Connection args for GET: %s', self._args)
|
||||
|
||||
def error() -> dict[str, typing.Any]:
|
||||
raise exceptions.rest.RequestError('Invalid Request')
|
||||
|
||||
return match_args(
|
||||
self._args,
|
||||
error,
|
||||
((), self.service_list),
|
||||
(('<ticketId>',), self.get_ticket_content),
|
||||
(('<idService>', '<idTransport>', 'udslink'), self.get_uds_link),
|
||||
(('<idService>', '<idTransport>', '<skip>'), self.connection),
|
||||
(('<idService>', '<idTransport>'), self.connection),
|
||||
(('<idService>', '<idTransport>', '<scrambler>', '<hostname>'), self.script),
|
||||
)
|
||||
@@ -1,60 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.ui import gui
|
||||
from uds.REST import Handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /auth path
|
||||
|
||||
|
||||
class Callback(Handler):
|
||||
"""
|
||||
API:
|
||||
Executes a callback from the GUI. Internal use, not intended to be called from outside.
|
||||
"""
|
||||
PATH = 'gui'
|
||||
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
def get(self) -> types.ui.CallbackResultType:
|
||||
if len(self._args) != 1:
|
||||
raise exceptions.rest.RequestError('Invalid Request')
|
||||
|
||||
if self._args[0] in gui.callbacks:
|
||||
return gui.callbacks[self._args[0]](self._params)
|
||||
|
||||
raise exceptions.rest.NotFound('callback {0} not found'.format(self._args[0]))
|
||||
@@ -1,116 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from uds.models import Image
|
||||
from uds.core import types
|
||||
from uds.core.util import ensure, ui as ui_utils
|
||||
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ImageItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
data: str = ''
|
||||
size: str = ''
|
||||
thumb: str = ''
|
||||
|
||||
|
||||
class Images(ModelHandler[ImageItem]):
|
||||
"""
|
||||
Handles the gallery REST interface
|
||||
"""
|
||||
|
||||
PATH = 'gallery'
|
||||
MODEL = Image
|
||||
FIELDS_TO_SAVE = ['name', 'data']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Image Gallery'))
|
||||
.image('thumb', _('Image'), width='96px')
|
||||
.text_column('name', _('Name'))
|
||||
.text_column('size', _('Size'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
fields['image'] = fields['data']
|
||||
del fields['data']
|
||||
# fields['data'] = Image.prepareForDb(Image.decode64(fields['data']))[2]
|
||||
|
||||
def post_save(self, item: 'models.Model') -> None:
|
||||
item = ensure.is_instance(item, Image)
|
||||
# Updates the thumbnail and re-saves it
|
||||
logger.debug('After save: item = %s', item)
|
||||
# item.updateThumbnail()
|
||||
# item.save()
|
||||
|
||||
# Note:
|
||||
# This has no get_gui because its treated on the admin or client.
|
||||
# We expect an Image List
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.UNTYPED,
|
||||
)
|
||||
|
||||
def get_item(self, item: 'models.Model') -> ImageItem:
|
||||
item = ensure.is_instance(item, Image)
|
||||
return ImageItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
data=item.data64,
|
||||
)
|
||||
|
||||
def get_item_summary(self, item: 'models.Model') -> ImageItem:
|
||||
item = ensure.is_instance(item, Image)
|
||||
return ImageItem(
|
||||
id=item.uuid,
|
||||
size='{}x{}, {} bytes (thumb {} bytes)'.format(
|
||||
item.width, item.height, len(item.data), len(item.thumb)
|
||||
),
|
||||
name=item.name,
|
||||
thumb=item.thumb64,
|
||||
)
|
||||
@@ -1,247 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 collections.abc
|
||||
import logging
|
||||
import time
|
||||
import typing
|
||||
|
||||
from uds.core import consts, exceptions
|
||||
from uds.core.auths.auth import authenticate
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core.util.cache import Cache
|
||||
from uds.core.util.config import GlobalConfig
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.models import Authenticator
|
||||
from uds.REST import Handler
|
||||
from uds.REST.utils import rest_result
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /auth path
|
||||
|
||||
|
||||
class Login(Handler):
|
||||
"""
|
||||
Responsible of user authentication
|
||||
"""
|
||||
|
||||
PATH = 'auth'
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
|
||||
@staticmethod
|
||||
def result(
|
||||
result: str = 'error',
|
||||
token: typing.Optional[str] = None,
|
||||
**kwargs: typing.Any,
|
||||
) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
# Valid kwargs are: error, scrambler
|
||||
return rest_result(result, token=token, **kwargs)
|
||||
|
||||
def post(self) -> typing.Any:
|
||||
"""
|
||||
This login uses parameters to generate auth token
|
||||
The alternative is to use the template tag inside "REST" that is called auth_token, that extracts an auth token from an user session
|
||||
We can use any of this forms due to the fact that the auth token is in fact a session key
|
||||
Parameters:
|
||||
mandatory:
|
||||
username:
|
||||
password:
|
||||
auth_id or auth or auth_label (authId and authSmallName for backwards compat, tbr): (must include at least one. If multiple are used, precedence is the list order)
|
||||
optional:
|
||||
platform: From what platform are we connecting. If not specified, will try to deduct it from user agent.
|
||||
Valid values:
|
||||
Linux = 'Linux'
|
||||
WindowsPhone = 'Windows Phone'
|
||||
Windows = 'Windows'
|
||||
Macintosh = 'MacOsX'
|
||||
Android = 'Android'
|
||||
iPad = 'iPad'
|
||||
iPhone = 'iPhone'
|
||||
Defaults to:
|
||||
Unknown = 'Unknown'
|
||||
|
||||
Result:
|
||||
on success: { 'result': 'ok', 'auth': [auth_code] }
|
||||
on error: { 'result: 'error', 'error': [error string] }
|
||||
|
||||
Locale comes on "Header", as any HTTP Request (Accept-Language header)
|
||||
Calls to any method of REST that must be authenticated needs to be called with "X-Auth-Token" Header added
|
||||
"""
|
||||
# Checks if client is "blocked"
|
||||
fail_cache = Cache('RESTapi')
|
||||
fails = fail_cache.get(self._request.ip) or 0
|
||||
if fails > consts.system.ALLOWED_FAILS:
|
||||
logger.info(
|
||||
'Access to REST API %s is blocked for %s seconds since last fail',
|
||||
self._request.ip,
|
||||
GlobalConfig.LOGIN_BLOCK.as_int(),
|
||||
)
|
||||
raise exceptions.rest.AccessDenied('Too many fails')
|
||||
|
||||
try:
|
||||
# if (
|
||||
# 'auth_id' not in self._params
|
||||
# and 'authId' not in self._params
|
||||
# and 'auth_id' not in self._params
|
||||
# and 'authSmallName' not in self._params
|
||||
# and 'authLabel' not in self._params
|
||||
# and 'auth_label' not in self._params
|
||||
# and 'auth' not in self._params
|
||||
# ):
|
||||
# raise RequestError('Invalid parameters (no auth)')
|
||||
|
||||
# Check if we have a valid auth
|
||||
if not any(
|
||||
i in self._params
|
||||
for i in ('auth_id', 'authId', 'authSmallName', 'authLabel', 'auth_label', 'auth')
|
||||
):
|
||||
raise exceptions.rest.RequestError('Invalid parameters (no auth)')
|
||||
|
||||
auth_id: typing.Optional[str] = self._params.get(
|
||||
'auth_id',
|
||||
self._params.get('authId', None), # Old compat, alias
|
||||
)
|
||||
auth_label: typing.Optional[str] = self._params.get(
|
||||
'auth_label',
|
||||
self._params.get(
|
||||
'authSmallName', # Old compat name
|
||||
self._params.get('authLabel', None), # Old compat name
|
||||
),
|
||||
)
|
||||
auth_name: typing.Optional[str] = self._params.get('auth', None)
|
||||
platform: str = self._params.get('platform', self._request.os.os.value[0])
|
||||
|
||||
username: str = self._params['username']
|
||||
password: str = self._params['password']
|
||||
locale: str = self._params.get('locale', 'en')
|
||||
|
||||
# Generate a random scrambler
|
||||
scrambler: str = CryptoManager.manager().random_string(32)
|
||||
if (
|
||||
auth_name == 'admin'
|
||||
or auth_label == 'admin'
|
||||
or auth_id == '00000000-0000-0000-0000-000000000000'
|
||||
or (not auth_id and not auth_name and not auth_label)
|
||||
):
|
||||
if GlobalConfig.SUPER_USER_LOGIN.get(True) == username and CryptoManager.manager().check_hash(
|
||||
password, GlobalConfig.SUPER_USER_PASS.get(True)
|
||||
):
|
||||
self.gen_auth_token(-1, username, password, locale, platform, scrambler)
|
||||
return Login.result(result='ok', token=self.get_auth_token())
|
||||
return Login.result(error='Invalid credentials')
|
||||
|
||||
# Will raise an exception if no auth found
|
||||
if auth_id:
|
||||
auth = Authenticator.objects.get(uuid=process_uuid(auth_id))
|
||||
elif auth_name:
|
||||
auth = Authenticator.objects.get(name__iexact=auth_name)
|
||||
else:
|
||||
auth = Authenticator.objects.get(small_name__iexact=auth_label)
|
||||
|
||||
# No matter in fact the password, just not empty (so it can be encrypted, but will be invalid anyway)
|
||||
password = password or CryptoManager().random_string(32)
|
||||
|
||||
logger.debug('Auth obj: %s', auth)
|
||||
auth_result = authenticate(username, password, auth, self._request)
|
||||
if auth_result.user is None: # invalid credentials
|
||||
# Sleep a while here to "prottect"
|
||||
time.sleep(3) # Wait 3 seconds if credentials fails for "protection"
|
||||
# And store in cache for blocking for a while if fails
|
||||
fail_cache.put(self._request.ip, fails + 1, GlobalConfig.LOGIN_BLOCK.as_int())
|
||||
|
||||
return Login.result(error=auth_result.errstr or 'Invalid credentials')
|
||||
return Login.result(
|
||||
result='ok',
|
||||
token=self.gen_auth_token(
|
||||
auth.id,
|
||||
auth_result.user.name,
|
||||
password,
|
||||
locale,
|
||||
platform,
|
||||
scrambler,
|
||||
),
|
||||
scrambler=scrambler,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Invalid credentials: %s: %s', self._params, e)
|
||||
pass
|
||||
|
||||
return Login.result(error='Invalid credentials')
|
||||
|
||||
|
||||
class Logout(Handler):
|
||||
"""
|
||||
Responsible of user de-authentication
|
||||
"""
|
||||
|
||||
PATH = 'auth'
|
||||
ROLE = consts.UserRole.USER # Must be logged in to logout :)
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
# Remove auth token
|
||||
self.clear_auth_token()
|
||||
return {'result': 'ok'}
|
||||
|
||||
def post(self) -> typing.Any:
|
||||
return self.get()
|
||||
|
||||
|
||||
class Auths(Handler):
|
||||
PATH = 'auth'
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
|
||||
def auths(self) -> collections.abc.Iterable[dict[str, typing.Any]]:
|
||||
all_param: bool = self._params.get('all', 'false').lower() == 'true'
|
||||
auth: Authenticator
|
||||
for auth in Authenticator.objects.all():
|
||||
auth_type = auth.get_type()
|
||||
if all_param or (auth_type.is_custom() is False and auth_type.type_type not in ('IP',)):
|
||||
yield {
|
||||
'authId': auth.uuid, # Deprecated, use 'auth_id'
|
||||
'auth_id': auth.uuid, # Deprecated, use 'id'
|
||||
'id': auth.uuid,
|
||||
'authSmallName': str(auth.small_name), # Deprecated
|
||||
'authLabel': str(auth.small_name), # Deprecated, use 'auth_label'
|
||||
'auth_label': str(auth.small_name), # Deprecated, use 'label'
|
||||
'label': str(auth.small_name),
|
||||
'auth': auth.name, # Deprecated, use 'name'
|
||||
'name': auth.name,
|
||||
'type': auth_type.type_type,
|
||||
'priority': auth.priority,
|
||||
'isCustom': auth_type.is_custom(), # Deprecated, use 'custom'
|
||||
'custom': auth_type.is_custom(),
|
||||
}
|
||||
|
||||
def get(self) -> list[dict[str, typing.Any]]:
|
||||
return list(self.auths())
|
||||
@@ -1,305 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from uds.core import types, exceptions
|
||||
from uds.core.consts.images import DEFAULT_THUMB_BASE64
|
||||
from uds.core import ui
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.types.states import State
|
||||
from uds.models import Image, MetaPool, ServicePoolGroup
|
||||
from uds.REST.methods.op_calendars import AccessCalendars
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
from .meta_service_pools import MetaAssignedService, MetaServicesPool
|
||||
from .user_services import Groups
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class MetaPoolItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
short_name: str
|
||||
tags: list[str]
|
||||
comments: str
|
||||
thumb: str
|
||||
image_id: str | None
|
||||
servicesPoolGroup_id: str | None
|
||||
pool_group_name: str | None
|
||||
pool_group_thumb: str | None
|
||||
user_services_count: int
|
||||
user_services_in_preparation: int
|
||||
visible: bool
|
||||
policy: str
|
||||
fallbackAccess: str
|
||||
permission: int
|
||||
calendar_message: str
|
||||
transport_grouping: int
|
||||
ha_policy: str
|
||||
|
||||
|
||||
class MetaPools(ModelHandler[MetaPoolItem]):
|
||||
"""
|
||||
Handles Services Pools REST requests
|
||||
"""
|
||||
|
||||
MODEL = MetaPool
|
||||
DETAIL = {
|
||||
'pools': MetaServicesPool,
|
||||
'services': MetaAssignedService,
|
||||
'groups': Groups,
|
||||
'access': AccessCalendars,
|
||||
}
|
||||
|
||||
FIELDS_TO_SAVE = [
|
||||
'name',
|
||||
'short_name',
|
||||
'comments',
|
||||
'tags',
|
||||
'image_id',
|
||||
'servicesPoolGroup_id',
|
||||
'visible',
|
||||
'policy',
|
||||
'ha_policy',
|
||||
'calendar_message',
|
||||
'transport_grouping',
|
||||
]
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Meta Pools'))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.dict_column(
|
||||
name='policy',
|
||||
title=_('Policy'),
|
||||
dct=dict(types.pools.LoadBalancingPolicy.enumerate()),
|
||||
)
|
||||
.dict_column(
|
||||
name='ha_policy',
|
||||
title=_('HA Policy'),
|
||||
dct=dict(types.pools.HighAvailabilityPolicy.enumerate()),
|
||||
)
|
||||
.numeric_column(name='user_services_count', title=_('User services'))
|
||||
.numeric_column(name='user_services_in_preparation', title=_('In Preparation'))
|
||||
.boolean(name='visible', title=_('Visible'))
|
||||
.text_column(name='pool_group_name', title=_('Pool Group'), width='16em')
|
||||
.text_column(name='short_name', title=_('Label'))
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('set_fallback_access', True),
|
||||
types.rest.ModelCustomMethod('get_fallback_access', True),
|
||||
]
|
||||
|
||||
# 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_item(self, item: 'models.Model') -> MetaPoolItem:
|
||||
item = ensure.is_instance(item, MetaPool)
|
||||
# if item does not have an associated service, hide it (the case, for example, for a removed service)
|
||||
# Access from dict will raise an exception, and item will be skipped
|
||||
pool_group_id = None
|
||||
pool_group_name = _('Default')
|
||||
pool_group_thumb = DEFAULT_THUMB_BASE64
|
||||
if item.servicesPoolGroup is not None:
|
||||
pool_group_id = item.servicesPoolGroup.uuid
|
||||
pool_group_name = item.servicesPoolGroup.name
|
||||
if item.servicesPoolGroup.image is not None:
|
||||
pool_group_thumb = item.servicesPoolGroup.image.thumb64
|
||||
|
||||
all_pools = item.members.all()
|
||||
userservices_total = sum(
|
||||
(i.pool.userServices.exclude(state__in=State.INFO_STATES).count() for i in all_pools)
|
||||
)
|
||||
userservices_in_preparation = sum(
|
||||
(i.pool.userServices.filter(state=State.PREPARING).count()) for i in all_pools
|
||||
)
|
||||
|
||||
return MetaPoolItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
short_name=item.short_name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
thumb=item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
|
||||
image_id=item.image.uuid if item.image is not None else None,
|
||||
servicesPoolGroup_id=pool_group_id,
|
||||
pool_group_name=pool_group_name,
|
||||
pool_group_thumb=pool_group_thumb,
|
||||
user_services_count=userservices_total,
|
||||
user_services_in_preparation=userservices_in_preparation,
|
||||
visible=item.visible,
|
||||
policy=str(item.policy),
|
||||
fallbackAccess=item.fallbackAccess,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
calendar_message=item.calendar_message,
|
||||
transport_grouping=item.transport_grouping,
|
||||
ha_policy=str(item.ha_policy),
|
||||
)
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_text(
|
||||
name='short_name',
|
||||
label=gettext('Short name'),
|
||||
tooltip=gettext('Short name for user service visualization'),
|
||||
length=32,
|
||||
)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.set_order(100)
|
||||
.add_multichoice(
|
||||
name='policy',
|
||||
label=gettext('Load balancing policy'),
|
||||
choices=[ui.gui.choice_item(k, str(v)) for k, v in types.pools.LoadBalancingPolicy.enumerate()],
|
||||
tooltip=gettext('Service pool load balancing policy'),
|
||||
)
|
||||
.add_choice(
|
||||
name='ha_policy',
|
||||
label=gettext('HA Policy'),
|
||||
choices=[
|
||||
ui.gui.choice_item(k, str(v)) for k, v in types.pools.HighAvailabilityPolicy.enumerate()
|
||||
],
|
||||
tooltip=gettext(
|
||||
'Service pool High Availability policy. If enabled and a pool fails, it will be restarted in another pool. Enable with care!'
|
||||
),
|
||||
)
|
||||
.new_tab(types.ui.Tab.DISPLAY)
|
||||
.add_image_choice()
|
||||
.add_image_choice(
|
||||
name='servicesPoolGroup_id',
|
||||
label=gettext('Pool group'),
|
||||
choices=[
|
||||
ui.gui.choice_image(
|
||||
x.uuid, x.name, x.image.thumb64 if x.image is not None else DEFAULT_THUMB_BASE64
|
||||
)
|
||||
for x in ServicePoolGroup.objects.all()
|
||||
],
|
||||
tooltip=gettext('Pool group for this pool (for pool classify on display)'),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='visible',
|
||||
label=gettext('Visible'),
|
||||
tooltip=gettext('If active, metapool will be visible for users'),
|
||||
default=True,
|
||||
)
|
||||
.add_text(
|
||||
name='calendar_message',
|
||||
label=gettext('Calendar access denied text'),
|
||||
tooltip=gettext('Custom message to be shown to users if access is limited by calendar rules.'),
|
||||
)
|
||||
.add_choice(
|
||||
name='transport_grouping', # Transport Selection
|
||||
label=gettext('Transport Selection'),
|
||||
choices=[
|
||||
ui.gui.choice_item(k, str(v)) for k, v in types.pools.TransportSelectionPolicy.enumerate()
|
||||
],
|
||||
tooltip=gettext('Transport selection policy'),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
# logger.debug(self._params)
|
||||
try:
|
||||
# **** IMAGE ***
|
||||
imgid = fields['image_id']
|
||||
fields['image_id'] = None
|
||||
logger.debug('Image id: %s', imgid)
|
||||
try:
|
||||
if imgid != '-1':
|
||||
image = Image.objects.get(uuid=process_uuid(imgid))
|
||||
fields['image_id'] = image.id
|
||||
except Exception:
|
||||
logger.exception('At image recovering')
|
||||
|
||||
# Servicepool Group
|
||||
servicespool_group_id = fields['servicesPoolGroup_id']
|
||||
fields['servicesPoolGroup_id'] = None
|
||||
logger.debug('servicesPoolGroup_id: %s', servicespool_group_id)
|
||||
try:
|
||||
if servicespool_group_id != '-1':
|
||||
spgrp = ServicePoolGroup.objects.get(uuid=process_uuid(servicespool_group_id))
|
||||
fields['servicesPoolGroup_id'] = spgrp.id
|
||||
except Exception:
|
||||
logger.exception('At service pool group recovering')
|
||||
|
||||
except (exceptions.rest.RequestError, exceptions.rest.ResponseError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise exceptions.rest.RequestError(str(e))
|
||||
|
||||
logger.debug('Fields: %s', fields)
|
||||
|
||||
def delete_item(self, item: 'models.Model') -> None:
|
||||
item = ensure.is_instance(item, MetaPool)
|
||||
item.delete()
|
||||
|
||||
# Set fallback status
|
||||
def set_fallback_access(self, item: MetaPool) -> typing.Any:
|
||||
"""
|
||||
API:
|
||||
Sets the fallback access for a metapool
|
||||
"""
|
||||
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
|
||||
|
||||
fallback = self._params.get('fallbackAccess', 'ALLOW')
|
||||
logger.debug('Setting fallback of %s to %s', item.name, fallback)
|
||||
item.fallbackAccess = fallback
|
||||
item.save()
|
||||
return ''
|
||||
|
||||
def get_fallback_access(self, item: MetaPool) -> typing.Any:
|
||||
return item.fallbackAccess
|
||||
|
||||
# Returns the action list based on current element, for calendars (nothing right now for metapools, because no actions are allowed)
|
||||
def actions_list(self, item: MetaPool) -> typing.Any:
|
||||
valid_actions = ()
|
||||
return valid_actions
|
||||
@@ -1,324 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2012-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 logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
from uds import models
|
||||
from uds.core import exceptions, types
|
||||
|
||||
# from uds.models.meta_pool import MetaPool, MetaPoolMember
|
||||
# from uds.models.service_pool import ServicePool
|
||||
# from uds.models.user_service import UserService
|
||||
# from uds.models.user import User
|
||||
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.types.states import State
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.util import log, ensure, ui as ui_utils
|
||||
from uds.REST.model import DetailHandler
|
||||
from .user_services import AssignedUserService, UserServiceItem
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class MetaItem(types.rest.BaseRestItem):
|
||||
"""
|
||||
Item type for a Meta Pool Member
|
||||
"""
|
||||
|
||||
id: str
|
||||
pool_id: str
|
||||
name: str
|
||||
comments: str
|
||||
priority: int
|
||||
enabled: bool
|
||||
user_services_count: int
|
||||
user_services_in_preparation: int
|
||||
|
||||
pool_name: str = '' # Optional
|
||||
|
||||
|
||||
class MetaServicesPool(DetailHandler[MetaItem]):
|
||||
"""
|
||||
Processes the transports detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def as_dict(item: models.MetaPoolMember) -> 'MetaItem':
|
||||
return MetaItem(
|
||||
id=item.uuid,
|
||||
pool_id=item.pool.uuid,
|
||||
name=item.pool.name,
|
||||
comments=item.pool.comments,
|
||||
priority=item.priority,
|
||||
enabled=item.enabled,
|
||||
user_services_count=item.pool.userServices.exclude(state__in=State.INFO_STATES).count(),
|
||||
user_services_in_preparation=item.pool.userServices.filter(state=State.PREPARING).count(),
|
||||
)
|
||||
|
||||
def get_item_position(self, parent: 'Model', item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
return self.calc_item_position(item_uuid, parent.members.all())
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult['MetaItem']:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
return [MetaServicesPool.as_dict(i) for i in self.filter_odata_queryset(parent.members.all())]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> 'MetaItem':
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
try:
|
||||
return MetaServicesPool.as_dict(parent.members.get(uuid=process_uuid(item)))
|
||||
except models.MetaPoolMember.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Meta pool member not found: {}').format(item)) from None
|
||||
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Members of {0}').format(parent.name))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='priority', title=_('Priority'))
|
||||
.text_column(name='enabled', title=_('Enabled'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
# If already exists
|
||||
uuid = process_uuid(item) if item else None
|
||||
|
||||
pool = models.ServicePool.objects.get(uuid=process_uuid(self._params['pool_id']))
|
||||
enabled = self._params['enabled'] not in ('false', False, '0', 0)
|
||||
priority = int(self._params['priority'])
|
||||
priority = priority if priority >= 0 else 0
|
||||
|
||||
if uuid is not None:
|
||||
member = parent.members.get(uuid=uuid)
|
||||
member.pool = pool
|
||||
member.enabled = enabled
|
||||
member.priority = priority
|
||||
member.save()
|
||||
else:
|
||||
member = parent.members.create(pool=pool, priority=priority, enabled=enabled)
|
||||
|
||||
log.log(
|
||||
parent,
|
||||
types.log.LogLevel.INFO,
|
||||
("Added" if uuid is None else "Modified")
|
||||
+ " meta pool member {}/{}/{} by {}".format(pool.name, priority, enabled, self._user.pretty_name),
|
||||
types.log.LogSource.ADMIN,
|
||||
)
|
||||
|
||||
return {'id': member.uuid}
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
member = parent.members.get(uuid=process_uuid(self._args[0]))
|
||||
log_str = "Removed meta pool member {} by {}".format(member.pool.name, self._user.pretty_name)
|
||||
|
||||
member.delete()
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
|
||||
|
||||
class MetaAssignedService(DetailHandler[UserServiceItem]):
|
||||
"""
|
||||
Rest handler for Assigned Services, wich parent is Service
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def item_as_dict(
|
||||
meta_pool: 'models.MetaPool',
|
||||
item: 'models.UserService',
|
||||
props: typing.Optional[dict[str, typing.Any]],
|
||||
) -> 'UserServiceItem':
|
||||
element = AssignedUserService.userservice_item(item, props, False)
|
||||
element.pool_id = item.deployed_service.uuid
|
||||
element.pool_name = item.deployed_service.name
|
||||
return element
|
||||
|
||||
@staticmethod
|
||||
def _get_assigned_userservice(metapool: models.MetaPool, userservice_id: str) -> models.UserService:
|
||||
"""
|
||||
Gets an assigned service and checks that it belongs to this metapool
|
||||
If not found, raises InvalidItemException
|
||||
"""
|
||||
found = models.UserService.objects.filter(
|
||||
uuid=process_uuid(userservice_id),
|
||||
cache_level=0,
|
||||
deployed_service__in=[i.pool for i in metapool.members.all()],
|
||||
).first()
|
||||
if found is None:
|
||||
raise exceptions.rest.NotFound(_('User service not found: {}').format(userservice_id)) from None
|
||||
return found
|
||||
|
||||
def _assigned_userservices_for_pools(
|
||||
self, parent: 'models.MetaPool'
|
||||
) -> typing.Generator[tuple[models.UserService, typing.Optional[dict[str, typing.Any]]], None, None]:
|
||||
for m in self.odata_filter(parent.members.filter(enabled=True)):
|
||||
properties: dict[str, typing.Any] = {
|
||||
k: v
|
||||
for k, v in models.Properties.objects.filter(
|
||||
owner_type='userservice',
|
||||
owner_id__in=m.pool.assigned_user_services().values_list('uuid', flat=True),
|
||||
).values_list('key', 'value')
|
||||
}
|
||||
for u in (
|
||||
m.pool.assigned_user_services()
|
||||
.filter(state__in=State.VALID_STATES)
|
||||
.prefetch_related('deployed_service', 'publication')
|
||||
):
|
||||
yield u, properties.get(u.uuid, {})
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult[UserServiceItem]:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
|
||||
return list(
|
||||
{
|
||||
k.uuid: MetaAssignedService.item_as_dict(parent, k, props)
|
||||
for k, props in self._assigned_userservices_for_pools(parent)
|
||||
}.values()
|
||||
)
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> UserServiceItem:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
|
||||
return MetaAssignedService.item_as_dict(
|
||||
parent,
|
||||
self._get_assigned_userservice(parent, item),
|
||||
props={
|
||||
k: v
|
||||
for k, v in models.Properties.objects.filter(
|
||||
owner_type='userservice', owner_id=process_uuid(item)
|
||||
).values_list('key', 'value')
|
||||
},
|
||||
)
|
||||
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Assigned services to {0}').format(parent.name))
|
||||
.datetime_column(name='creation_date', title=_('Creation date'))
|
||||
.text_column(name='pool_name', title=_('Pool'))
|
||||
.text_column(name='unique_id', title='Unique ID')
|
||||
.text_column(name='ip', title=_('IP'))
|
||||
.text_column(name='friendly_name', title=_('Friendly name'))
|
||||
.dict_column(name='state', title=_('status'), dct=State.literals_dict())
|
||||
.text_column(name='in_use', title=_('In Use'))
|
||||
.text_column(name='source_host', title=_('Src Host'))
|
||||
.text_column(name='source_ip', title=_('Src Ip'))
|
||||
.text_column(name='owner', title=_('Owner'))
|
||||
.text_column(name='actor_version', title=_('Actor version'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
try:
|
||||
assigned_userservice = self._get_assigned_userservice(parent, item)
|
||||
logger.debug('Getting logs for %s', assigned_userservice)
|
||||
return log.get_logs(assigned_userservice)
|
||||
except exceptions.rest.HandlerError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Error getting logs for %s', e)
|
||||
raise exceptions.rest.RequestError(f'Error retrieving logs for assigned service: {e}') from e
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
userservice = self._get_assigned_userservice(parent, item)
|
||||
|
||||
if userservice.user:
|
||||
log_str = 'Deleted assigned service {} to user {} by {}'.format(
|
||||
userservice.friendly_name,
|
||||
userservice.user.pretty_name,
|
||||
self._user.pretty_name,
|
||||
)
|
||||
else:
|
||||
log_str = 'Deleted cached service {} by {}'.format(
|
||||
userservice.friendly_name, self._user.pretty_name
|
||||
)
|
||||
|
||||
if userservice.state in (State.USABLE, State.REMOVING):
|
||||
userservice.release()
|
||||
elif userservice.state == State.PREPARING:
|
||||
userservice.cancel()
|
||||
elif userservice.state == State.REMOVABLE:
|
||||
raise exceptions.rest.RequestError(_('Item already being removed'))
|
||||
else:
|
||||
raise exceptions.rest.RequestError(_('Item is not removable'))
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
|
||||
# Only owner is allowed to change right now
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
if item is None:
|
||||
raise exceptions.rest.RequestError(_('Invalid item specified'))
|
||||
|
||||
fields = self.fields_from_params(['auth_id', 'user_id'])
|
||||
userservice = self._get_assigned_userservice(parent, item)
|
||||
user = models.User.objects.get(uuid=process_uuid(fields['user_id']))
|
||||
|
||||
log_str = 'Changing ownership of service from {} to {} by {}'.format(
|
||||
userservice.user.pretty_name if userservice.user else 'unknown',
|
||||
user.pretty_name,
|
||||
self._user.pretty_name,
|
||||
)
|
||||
|
||||
# If there is another service that has this same owner, raise an exception
|
||||
if (
|
||||
userservice.deployed_service.userServices.filter(user=user)
|
||||
.exclude(uuid=userservice.uuid)
|
||||
.exclude(state__in=State.INFO_STATES)
|
||||
.count()
|
||||
> 0
|
||||
):
|
||||
raise exceptions.rest.RequestError(
|
||||
'There is already another user service assigned to {}'.format(user.pretty_name)
|
||||
)
|
||||
|
||||
userservice.user = user
|
||||
userservice.save()
|
||||
|
||||
# Log change
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
|
||||
return {'id': userservice.uuid}
|
||||
@@ -1,138 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2021 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 collections.abc
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from uds import models
|
||||
from uds.core import exceptions, mfas, types
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.util import ui as ui_utils
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class MFAItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
remember_device: int
|
||||
validity: int
|
||||
tags: list[str]
|
||||
comments: str
|
||||
type: str
|
||||
type_name: str
|
||||
permission: int
|
||||
|
||||
|
||||
class MFA(ModelHandler[MFAItem]):
|
||||
|
||||
MODEL = models.MFA
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags', 'remember_device', 'validity']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Multi Factor Authentication'))
|
||||
.icon(name='name', title=_('Name'), visible=True)
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.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,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[mfas.MFA]]:
|
||||
return mfas.factory().providers().values()
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
mfa_type = mfas.factory().lookup(for_type)
|
||||
|
||||
if not mfa_type:
|
||||
raise exceptions.rest.NotFound(_('MFA type not found: {}').format(for_type))
|
||||
|
||||
# Create a temporal instance to get the gui
|
||||
with Environment.temporary_environment() as env:
|
||||
mfa = mfa_type(env, None)
|
||||
|
||||
return (
|
||||
ui_utils.GuiBuilder(100)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(
|
||||
types.rest.stock.StockField.TAGS,
|
||||
)
|
||||
.add_fields(mfa.gui_description())
|
||||
.add_numeric(
|
||||
name='remember_device',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Device Caching'),
|
||||
tooltip=gettext('Time in hours to cache device so MFA is not required again. User based.'),
|
||||
)
|
||||
.add_numeric(
|
||||
name='validity',
|
||||
default=5,
|
||||
min_value=0,
|
||||
label=gettext('MFA code validity'),
|
||||
tooltip=gettext('Time in minutes to allow MFA code to be used.'),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_item(self, item: 'Model') -> MFAItem:
|
||||
item = ensure.is_instance(item, models.MFA)
|
||||
type_ = item.get_type()
|
||||
return MFAItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
remember_device=item.remember_device,
|
||||
validity=item.validity,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
type=type_.mod_type(),
|
||||
type_name=type_.mod_name(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
@@ -1,112 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2023 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
|
||||
from django.utils.translation import gettext_lazy as _, gettext
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.models import Network
|
||||
from uds.core import types
|
||||
from uds.core.util import permissions, ensure, ui as ui_utils
|
||||
|
||||
from ..model import ModelHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class NetworkItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
net_string: str
|
||||
transports_count: int
|
||||
authenticators_count: int
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
|
||||
class Networks(ModelHandler[NetworkItem]):
|
||||
"""
|
||||
Processes REST requests about networks
|
||||
Implements specific handling for network related requests using GUI
|
||||
"""
|
||||
|
||||
MODEL = Network
|
||||
FIELDS_TO_SAVE = ['name', 'net_string', 'tags']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Networks'))
|
||||
.text_column('name', _('Name'))
|
||||
.text_column('net_string', _('Range'))
|
||||
.numeric_column('transports_count', _('Transports'), width='8em')
|
||||
.numeric_column('authenticators_count', _('Authenticators'), width='8em')
|
||||
.text_column('tags', _('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.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_text(
|
||||
name='net_string',
|
||||
label=gettext('Network range'),
|
||||
tooltip=gettext(
|
||||
'Network range. Accepts most network definitions formats (range, subnet, host, etc...)'
|
||||
),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_item(self, item: 'Model') -> NetworkItem:
|
||||
item = ensure.is_instance(item, Network)
|
||||
return NetworkItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
net_string=item.net_string,
|
||||
transports_count=item.transports.count(),
|
||||
authenticators_count=item.authenticators.count(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
@@ -1,145 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2021 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 collections.abc
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from uds.core import exceptions, messaging, types
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.util import ui as ui_utils
|
||||
from uds.models import LogLevel, Notifier
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class NotifierItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
level: str
|
||||
enabled: bool
|
||||
tags: list[str]
|
||||
comments: str
|
||||
type: str
|
||||
type_name: str
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
|
||||
class Notifiers(ModelHandler[NotifierItem]):
|
||||
|
||||
PATH = 'messaging'
|
||||
MODEL = Notifier
|
||||
FIELDS_TO_SAVE = [
|
||||
'name',
|
||||
'comments',
|
||||
'level',
|
||||
'tags',
|
||||
'enabled',
|
||||
]
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Notifiers'))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='level', title=_('Level'))
|
||||
.boolean(name='enabled', title=_('Enabled'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.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,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[messaging.Notifier]]:
|
||||
return messaging.factory().providers().values()
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
notifier_type = messaging.factory().lookup(for_type)
|
||||
|
||||
if not notifier_type:
|
||||
raise exceptions.rest.NotFound(_('Notifier type not found: {}').format(for_type))
|
||||
|
||||
with Environment.temporary_environment() as env:
|
||||
notifier = notifier_type(env, None)
|
||||
|
||||
return (
|
||||
(
|
||||
ui_utils.GuiBuilder(100)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
)
|
||||
.add_fields(notifier.gui_description())
|
||||
.add_choice(
|
||||
name='level',
|
||||
choices=[gui.choice_item(i[0], i[1]) for i in LogLevel.interesting()],
|
||||
label=gettext('Level'),
|
||||
tooltip=gettext('Level of notifications'),
|
||||
default=str(LogLevel.ERROR.value),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='enabled',
|
||||
label=gettext('Enabled'),
|
||||
tooltip=gettext('If checked, this notifier will be used'),
|
||||
default=True,
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_item(self, item: 'Model') -> NotifierItem:
|
||||
item = ensure.is_instance(item, Notifier)
|
||||
type_ = item.get_type()
|
||||
return NotifierItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
level=str(item.level),
|
||||
enabled=item.enabled,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
type=type_.mod_type(),
|
||||
type_name=type_.mod_name(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
@@ -1,300 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-2019 Virtual Cable S.L.
|
||||
# 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 json
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.util import log, ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds import models
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOW = 'ALLOW'
|
||||
DENY = 'DENY'
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AccessCalendarItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
calendar_id: str
|
||||
calendar: str
|
||||
access: str
|
||||
priority: int
|
||||
|
||||
|
||||
class AccessCalendars(DetailHandler[AccessCalendarItem]):
|
||||
@staticmethod
|
||||
def as_item(item: 'models.CalendarAccess|models.CalendarAccessMeta') -> AccessCalendarItem:
|
||||
return AccessCalendarItem(
|
||||
id=item.uuid,
|
||||
calendar_id=item.calendar.uuid,
|
||||
calendar=item.calendar.name,
|
||||
access=item.access,
|
||||
priority=item.priority,
|
||||
)
|
||||
|
||||
def get_item_position(self, parent: 'Model', item_uuid: str) -> int:
|
||||
# parent can be a ServicePool or a metaPool
|
||||
if isinstance(parent, models.ServicePool):
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return self.calc_item_position(item_uuid, parent.calendarAccess.all())
|
||||
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
return self.calc_item_position(item_uuid, parent.calendarAccess.all())
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult[AccessCalendarItem]:
|
||||
# parent can be a ServicePool or a metaPool
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
|
||||
return [AccessCalendars.as_item(i) for i in self.filter_odata_queryset(parent.calendarAccess.all())]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> AccessCalendarItem:
|
||||
# parent can be a ServicePool or a metaPool
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
|
||||
return AccessCalendars.as_item(parent.calendarAccess.get(uuid=process_uuid(item)))
|
||||
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Access calendars'))
|
||||
.numeric_column('priority', _('Priority'))
|
||||
.text_column('calendar', _('Calendar'))
|
||||
.text_column('access', _('Access'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
# If already exists
|
||||
uuid = process_uuid(item) if item is not None else None
|
||||
|
||||
try:
|
||||
calendar: models.Calendar = models.Calendar.objects.get(
|
||||
uuid=process_uuid(self._params['calendar_id'])
|
||||
)
|
||||
access: str = self._params['access'].upper()
|
||||
if access not in (ALLOW, DENY):
|
||||
raise Exception()
|
||||
except models.Calendar.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(
|
||||
_('Calendar not found: {}').format(self._params['calendar_id'])
|
||||
) from None
|
||||
except Exception as e:
|
||||
logger.error('Error saving calendar access: %s', e)
|
||||
raise exceptions.rest.RequestError(_('Invalid parameters on request')) from e
|
||||
|
||||
priority = int(self._params['priority'])
|
||||
|
||||
if uuid is not None:
|
||||
calendar_access = parent.calendarAccess.get(uuid=uuid)
|
||||
calendar_access.calendar = calendar
|
||||
calendar_access.access = access
|
||||
calendar_access.priority = priority
|
||||
calendar_access.save(update_fields=['calendar', 'access', 'priority'])
|
||||
else:
|
||||
calendar_access = parent.calendarAccess.create(calendar=calendar, access=access, priority=priority)
|
||||
|
||||
log.log(
|
||||
parent,
|
||||
types.log.LogLevel.INFO,
|
||||
f'{"Added" if uuid is None else "Updated"} access calendar {calendar.name}/{access} by {self._user.pretty_name}',
|
||||
types.log.LogSource.ADMIN,
|
||||
)
|
||||
|
||||
return {'id': calendar_access.uuid}
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
calendar_access = parent.calendarAccess.get(uuid=process_uuid(self._args[0]))
|
||||
log_str = f'Removed access calendar {calendar_access.calendar.name} by {self._user.pretty_name}'
|
||||
calendar_access.delete()
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ActionCalendarItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
calendar_id: str
|
||||
calendar: str
|
||||
action: str
|
||||
description: str
|
||||
at_start: bool
|
||||
events_offset: int
|
||||
params: dict[str, typing.Any]
|
||||
pretty_params: str
|
||||
next_execution: typing.Optional[datetime.datetime]
|
||||
last_execution: typing.Optional[datetime.datetime]
|
||||
|
||||
|
||||
class ActionsCalendars(DetailHandler[ActionCalendarItem]):
|
||||
"""
|
||||
Processes the transports detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
CUSTOM_METHODS = [
|
||||
'execute',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def as_dict(item: 'models.CalendarAction') -> ActionCalendarItem:
|
||||
action = consts.calendar.CALENDAR_ACTION_DICT.get(item.action)
|
||||
descrption = action.get('description') if action is not None else ''
|
||||
params = json.loads(item.params)
|
||||
return ActionCalendarItem(
|
||||
id=item.uuid,
|
||||
calendar_id=item.calendar.uuid,
|
||||
calendar=item.calendar.name,
|
||||
action=item.action,
|
||||
description=descrption,
|
||||
at_start=item.at_start,
|
||||
events_offset=item.events_offset,
|
||||
params=params,
|
||||
pretty_params=item.pretty_params,
|
||||
next_execution=item.next_execution,
|
||||
last_execution=item.last_execution,
|
||||
)
|
||||
|
||||
def get_item_position(self, parent: 'Model', item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return self.calc_item_position(item_uuid, parent.calendaraction_set.all())
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult[ActionCalendarItem]:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return [
|
||||
ActionsCalendars.as_dict(i) for i in self.filter_odata_queryset(parent.calendaraction_set.all())
|
||||
]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> ActionCalendarItem:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return ActionsCalendars.as_dict(parent.calendaraction_set.get(uuid=process_uuid(item)))
|
||||
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Scheduled actions'))
|
||||
.text_column('calendar', _('Calendar'))
|
||||
.text_column('description', _('Action'))
|
||||
.text_column('pretty_params', _('Parameters'))
|
||||
.dict_column('at_start', _('Relative to'), dct={True: _('Start'), False: _('End')})
|
||||
.text_column('events_offset', _('Time offset'))
|
||||
.datetime_column('next_execution', _('Next execution'))
|
||||
.datetime_column('last_execution', _('Last execution'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
# If already exists
|
||||
uuid = process_uuid(item) if item is not None else None
|
||||
|
||||
calendar = models.Calendar.objects.get(uuid=process_uuid(self._params['calendar_id']))
|
||||
action = self._params['action'].upper()
|
||||
if action not in consts.calendar.CALENDAR_ACTION_DICT:
|
||||
raise exceptions.rest.RequestError(_('Invalid action: {}').format(action))
|
||||
events_offset = int(self._params['events_offset'])
|
||||
at_start = self._params['at_start'] not in ('false', False, '0', 0)
|
||||
params = json.dumps(self._params['params'])
|
||||
|
||||
# logger.debug('Got parameters: {} {} {} {} ----> {}'.format(calendar, action, events_offset, at_start, params))
|
||||
log_string = (
|
||||
f'{"Added" if uuid is None else "Updated"} scheduled action '
|
||||
f'{calendar.name},{action},{events_offset},{"start" if at_start else "end"},{params} '
|
||||
f'by {self._user.pretty_name}'
|
||||
)
|
||||
|
||||
if uuid is not None:
|
||||
calendar_action = models.CalendarAction.objects.get(uuid=uuid)
|
||||
calendar_action.calendar = calendar
|
||||
calendar_action.service_pool = parent
|
||||
calendar_action.action = action
|
||||
calendar_action.at_start = at_start
|
||||
calendar_action.events_offset = events_offset
|
||||
calendar_action.params = params
|
||||
calendar_action.save()
|
||||
else:
|
||||
calendar_action = models.CalendarAction.objects.create(
|
||||
calendar=calendar,
|
||||
service_pool=parent,
|
||||
action=action,
|
||||
at_start=at_start,
|
||||
events_offset=events_offset,
|
||||
params=params,
|
||||
)
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
|
||||
|
||||
return {'id': calendar_action.uuid}
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
calendar_action = models.CalendarAction.objects.get(uuid=process_uuid(self._args[0]))
|
||||
log_str = (
|
||||
f'Removed scheduled action "{calendar_action.calendar.name},'
|
||||
f'{calendar_action.action},{calendar_action.events_offset},'
|
||||
f'{calendar_action.at_start and "Start" or "End"},'
|
||||
f'{calendar_action.params}" by {self._user.pretty_name}'
|
||||
)
|
||||
|
||||
calendar_action.delete()
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
|
||||
def execute(self, parent: 'Model', item: str) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
logger.debug('Launching action')
|
||||
uuid = process_uuid(item)
|
||||
calendar_action: models.CalendarAction = models.CalendarAction.objects.get(uuid=uuid)
|
||||
self.check_access(calendar_action, types.permissions.PermissionType.MANAGEMENT)
|
||||
|
||||
log_str = (
|
||||
f'Launched scheduled action "{calendar_action.calendar.name},'
|
||||
f'{calendar_action.action},{calendar_action.events_offset},'
|
||||
f'{calendar_action.at_start and "Start" or "End"},'
|
||||
f'{calendar_action.params}" by {self._user.pretty_name}'
|
||||
)
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
calendar_action.execute()
|
||||
|
||||
return self.success()
|
||||
@@ -1,137 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 collections.abc
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from uds.core import exceptions, osmanagers, types
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.util import ui as ui_utils
|
||||
from uds.models import OSManager
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /osm path
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class OsManagerItem(types.rest.ManagedObjectItem[OSManager]):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
deployed_count: int
|
||||
servicesTypes: list[str]
|
||||
comments: str
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
|
||||
class OsManagers(ModelHandler[OsManagerItem]):
|
||||
|
||||
MODEL = OSManager
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('OS Managers'))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='deployed_count', title=_('Used by'), width='8em')
|
||||
.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 os_manager_as_dict(self, item: OSManager) -> OsManagerItem:
|
||||
type_ = item.get_type()
|
||||
ret_value = OsManagerItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
deployed_count=item.deployedServices.count(),
|
||||
servicesTypes=[
|
||||
type_.services_types
|
||||
], # A list for backward compatibility. TODO: To be removed when admin interface is changed
|
||||
comments=item.comments,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
item=item,
|
||||
)
|
||||
# Fill type and type_name
|
||||
return ret_value
|
||||
|
||||
def get_item(self, item: 'Model') -> OsManagerItem:
|
||||
item = ensure.is_instance(item, OSManager)
|
||||
return self.os_manager_as_dict(item)
|
||||
|
||||
def validate_delete(self, item: 'Model') -> None:
|
||||
item = ensure.is_instance(item, OSManager)
|
||||
# Only can delete if no ServicePools attached
|
||||
if item.deployedServices.count() > 0:
|
||||
raise exceptions.rest.RequestError(
|
||||
gettext('Can\'t delete an OS Manager with services pools associated')
|
||||
)
|
||||
|
||||
# Types related
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[osmanagers.OSManager]]:
|
||||
return osmanagers.factory().providers().values()
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
try:
|
||||
osmanager_type = osmanagers.factory().lookup(for_type)
|
||||
|
||||
if not osmanager_type:
|
||||
raise exceptions.rest.NotFound('OS Manager type not found')
|
||||
with Environment.temporary_environment() as env:
|
||||
osmanager = osmanager_type(env, None)
|
||||
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_fields(osmanager.gui_description())
|
||||
.build()
|
||||
)
|
||||
except:
|
||||
raise exceptions.rest.NotFound(_('OS Manager type not found: {}').format(for_type))
|
||||
@@ -1,181 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-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 collections.abc
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
import uds.core.types.permissions
|
||||
from uds import models
|
||||
from uds.core import consts, exceptions
|
||||
from uds.core.util import permissions
|
||||
from uds.core.util.rest.tools import match_args
|
||||
from uds.REST import Handler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Enclosed methods under /permissions path
|
||||
class Permissions(Handler):
|
||||
"""
|
||||
Processes permissions requests
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
@staticmethod
|
||||
def get_class(class_name: str) -> type['Model']:
|
||||
cls = {
|
||||
'providers': models.Provider,
|
||||
'service': models.Service,
|
||||
'authenticators': models.Authenticator,
|
||||
'osmanagers': models.OSManager,
|
||||
'transports': models.Transport,
|
||||
'networks': models.Network,
|
||||
'servicespools': models.ServicePool,
|
||||
'calendars': models.Calendar,
|
||||
'metapools': models.MetaPool,
|
||||
'accounts': models.Account,
|
||||
'mfa': models.MFA,
|
||||
'servers-groups': models.ServerGroup,
|
||||
'tunnels-tunnels': models.ServerGroup, # Same as servers-groups, but different items
|
||||
}.get(class_name, None)
|
||||
|
||||
if cls is None:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
return cls
|
||||
|
||||
@staticmethod
|
||||
def as_dict(
|
||||
perms: collections.abc.Iterable[models.Permissions],
|
||||
) -> list[dict[str, str]]:
|
||||
res: list[dict[str, typing.Any]] = []
|
||||
entity: typing.Optional[typing.Union[models.User, models.Group]]
|
||||
for perm in perms:
|
||||
if perm.user is None:
|
||||
kind = 'group'
|
||||
entity = perm.group
|
||||
else:
|
||||
kind = 'user'
|
||||
entity = perm.user
|
||||
|
||||
# If entity is None, it means that the permission is not valid anymore (user or group deleted on db manually?)
|
||||
if entity:
|
||||
res.append(
|
||||
{
|
||||
'id': perm.uuid,
|
||||
'type': kind,
|
||||
'auth': entity.manager.uuid,
|
||||
'auth_name': entity.manager.name,
|
||||
'entity_id': entity.uuid,
|
||||
'entity_name': entity.name,
|
||||
'perm': perm.permission,
|
||||
'perm_name': perm.as_str,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(res, key=lambda v: v['auth_name'] + v['entity_name'])
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
"""
|
||||
Processes get requests
|
||||
"""
|
||||
logger.debug('Permissions args for GET: %s', self._args)
|
||||
|
||||
# Update some XXX/YYYY to XXX-YYYY (as server/groups, that is a valid class name)
|
||||
if len(self._args) == 3:
|
||||
self._args = [self._args[0] + '-' + self._args[1], self._args[2]]
|
||||
|
||||
if len(self._args) != 2:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
item_class = Permissions.get_class(self._args[0])
|
||||
obj: 'Model' = item_class.objects.get(uuid=self._args[1])
|
||||
|
||||
return Permissions.as_dict(permissions.get_permissions(obj))
|
||||
|
||||
def put(self) -> typing.Any:
|
||||
"""
|
||||
Processes put requests
|
||||
"""
|
||||
logger.debug('Put args: %s', self._args)
|
||||
|
||||
# Update some XXX/YYYY to XXX-YYYY (as server/groups, that is a valid class name)
|
||||
if len(self._args) == 6:
|
||||
self._args = [
|
||||
self._args[0] + '-' + self._args[1],
|
||||
self._args[2],
|
||||
self._args[3],
|
||||
self._args[4],
|
||||
self._args[5],
|
||||
]
|
||||
|
||||
if len(self._args) != 5 and len(self._args) != 1:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
perm = uds.core.types.permissions.PermissionType.from_str(self._params.get('perm', '0'))
|
||||
|
||||
def add_user_permission(cls_param: str, obj_param: str, user_param: str) -> list[dict[str, str]]:
|
||||
cls = Permissions.get_class(cls_param)
|
||||
obj = cls.objects.get(uuid=obj_param)
|
||||
user = models.User.objects.get(uuid=user_param)
|
||||
permissions.add_user_permission(user, obj, perm)
|
||||
return Permissions.as_dict(permissions.get_permissions(obj))
|
||||
|
||||
def add_group_permission(cls_param: str, obj_param: str, group_param: str) -> list[dict[str, str]]:
|
||||
cls = Permissions.get_class(cls_param)
|
||||
obj = cls.objects.get(uuid=obj_param)
|
||||
group = models.Group.objects.get(uuid=group_param)
|
||||
permissions.add_group_permission(group, obj, perm)
|
||||
return Permissions.as_dict(permissions.get_permissions(obj))
|
||||
|
||||
def revoke() -> list[dict[str, str]]:
|
||||
for perm_id in self._params.get('items', []):
|
||||
permissions.revoke_permission_by_id(perm_id)
|
||||
return []
|
||||
|
||||
def no_match() -> None:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
# match is a helper function that will match the args with the given patterns
|
||||
return match_args(
|
||||
self._args,
|
||||
no_match,
|
||||
(('<cls>', '<obj>', 'users', 'add', '<user>'), add_user_permission),
|
||||
(('<cls>', '<obj>', 'groups', 'add', '<group>'), add_group_permission),
|
||||
(('revoke',), revoke),
|
||||
)
|
||||
@@ -1,214 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-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 collections.abc
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
|
||||
import uds.core.types.permissions
|
||||
from uds.core import exceptions, services, types
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.types.states import State
|
||||
from uds.models import Provider, Service, UserService
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
from .services import Services as DetailServices
|
||||
from .services_usage import ServicesUsage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Helper class for Provider offers
|
||||
@dataclasses.dataclass
|
||||
class OfferItem(types.rest.BaseRestItem):
|
||||
name: str
|
||||
type: str
|
||||
description: str
|
||||
icon: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ProviderItem(types.rest.ManagedObjectItem[Provider]):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
services_count: int
|
||||
user_services_count: int
|
||||
maintenance_mode: bool
|
||||
offers: list[OfferItem]
|
||||
comments: str
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
|
||||
class Providers(ModelHandler[ProviderItem]):
|
||||
|
||||
MODEL = Provider
|
||||
DETAIL = {'services': DetailServices, 'usage': ServicesUsage}
|
||||
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('allservices', False),
|
||||
types.rest.ModelCustomMethod('service', False),
|
||||
types.rest.ModelCustomMethod('maintenance', True),
|
||||
]
|
||||
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Service providers'))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='services_count', title=_('Services'))
|
||||
.numeric_column(name='user_services_count', title=_('User Services'))
|
||||
.text_column(name='tags', title=_('Tags'), visible=False)
|
||||
.row_style(prefix='row-maintenance-', field='maintenance_mode')
|
||||
).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 get_item(self, item: 'Model') -> ProviderItem:
|
||||
item = ensure.is_instance(item, Provider)
|
||||
type_ = item.get_type()
|
||||
|
||||
# Icon can have a lot of data (1-2 Kbytes), but it's not expected to have a lot of services providers, and even so, this will work fine
|
||||
offers: list[OfferItem] = [
|
||||
OfferItem(
|
||||
name=gettext(t.mod_name()),
|
||||
type=t.mod_type(),
|
||||
description=gettext(t.description()),
|
||||
icon=t.icon64().replace('\n', ''),
|
||||
)
|
||||
for t in type_.get_provided_services()
|
||||
]
|
||||
|
||||
return ProviderItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.vtag for tag in item.tags.all()],
|
||||
services_count=item.services.count(),
|
||||
user_services_count=UserService.objects.filter(deployed_service__service__provider=item)
|
||||
.exclude(state__in=(State.REMOVED, State.ERROR))
|
||||
.count(),
|
||||
maintenance_mode=item.maintenance_mode,
|
||||
offers=offers,
|
||||
comments=item.comments,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
item=item,
|
||||
)
|
||||
|
||||
def validate_delete(self, item: 'Model') -> None:
|
||||
item = ensure.is_instance(item, Provider)
|
||||
if item.services.count() > 0:
|
||||
raise exceptions.rest.RequestError(gettext('Can\'t delete providers with services'))
|
||||
|
||||
# Types related
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[services.ServiceProvider]]:
|
||||
return services.factory().providers().values()
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
provider_type = services.factory().lookup(for_type)
|
||||
if provider_type:
|
||||
with Environment.temporary_environment() as env:
|
||||
provider = provider_type(env, None)
|
||||
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_fields(provider.gui_description(), parent='instance')
|
||||
).build()
|
||||
|
||||
raise exceptions.rest.NotFound('Type not found!')
|
||||
|
||||
def allservices(self) -> typing.Generator[types.rest.BaseRestItem, None, None]:
|
||||
"""
|
||||
Custom method that returns "all existing services", no mater who's his daddy :)
|
||||
"""
|
||||
for s in Service.objects.all():
|
||||
try:
|
||||
perm = permissions.effective_permissions(self._user, s)
|
||||
if perm >= uds.core.types.permissions.PermissionType.READ:
|
||||
yield DetailServices.service_item(s, perm, True)
|
||||
except Exception:
|
||||
logger.exception('Passed service cause type is unknown')
|
||||
|
||||
def service(self) -> types.rest.BaseRestItem:
|
||||
"""
|
||||
Custom method that returns a service by its uuid, no matter who's his daddy
|
||||
"""
|
||||
try:
|
||||
service = Service.objects.get(uuid=self._args[1])
|
||||
self.check_access(service.provider, uds.core.types.permissions.PermissionType.READ)
|
||||
perm = self.get_permissions(service.provider)
|
||||
return DetailServices.service_item(service, perm, True)
|
||||
except Exception:
|
||||
# logger.exception('Exception')
|
||||
return types.rest.BaseRestItem()
|
||||
|
||||
def maintenance(self, item: 'Model') -> types.rest.BaseRestItem:
|
||||
"""
|
||||
Custom method that swaps maintenance mode state for a provider
|
||||
:param item:
|
||||
"""
|
||||
item = ensure.is_instance(item, Provider)
|
||||
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
item.maintenance_mode = not item.maintenance_mode
|
||||
item.save()
|
||||
return self.get_item(item)
|
||||
|
||||
def test(self, type_: str) -> str:
|
||||
from uds.core.environment import Environment
|
||||
|
||||
logger.debug('Type: %s', type_)
|
||||
provider_type = services.factory().lookup(type_)
|
||||
|
||||
if not provider_type:
|
||||
raise exceptions.rest.NotFound('Type not found!')
|
||||
|
||||
with Environment.temporary_environment() as temp_environment:
|
||||
logger.debug('spType: %s', provider_type)
|
||||
|
||||
# On 5.0 onwards, instance comes inside "instance" key
|
||||
dct = self._params.copy()['instance']
|
||||
dct['_request'] = self._request
|
||||
test_result = provider_type.test(temp_environment, dct)
|
||||
return 'ok' if test_result.success else test_result.error
|
||||
@@ -1,179 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.util.rest.tools import match_args
|
||||
from uds.core.util import ui as ui_utils
|
||||
from uds.REST import model
|
||||
from uds import reports
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core.reports.report import Report
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_PARAMS = (
|
||||
'authId',
|
||||
'authSmallName',
|
||||
'auth',
|
||||
'username',
|
||||
'realname',
|
||||
'password',
|
||||
'groups',
|
||||
'servicePool',
|
||||
'transport',
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ReportItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
mime_type: str
|
||||
encoded: bool
|
||||
group: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
# Enclosed methods under /actor path
|
||||
class Reports(model.BaseModelHandler[ReportItem]):
|
||||
"""
|
||||
Processes reports requests
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Available reports'))
|
||||
.text_column(name='group', title=_('Group'), visible=True)
|
||||
.text_column(name='name', title=_('Name'), visible=True)
|
||||
.text_column(name='description', title=_('Description'), visible=True)
|
||||
.text_column(name='mime_type', title=_('Generates'), visible=True)
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.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 _locate_report(
|
||||
self, uuid: str, values: typing.Optional[typing.Dict[str, typing.Any]] = None
|
||||
) -> 'Report':
|
||||
found = None
|
||||
logger.debug('Looking for report %s', uuid)
|
||||
for i in reports.available_reports:
|
||||
if i.get_uuid() == uuid:
|
||||
found = i(values)
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise exceptions.rest.NotFound(f'Report not found: {uuid}') from None
|
||||
|
||||
return found
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
logger.debug('method GET for %s, %s', self.__class__.__name__, self._args)
|
||||
|
||||
def error() -> typing.NoReturn:
|
||||
raise exceptions.rest.RequestError('Invalid report uuid!')
|
||||
|
||||
def report_gui(report_id: str) -> typing.Any:
|
||||
return self.get_gui(report_id)
|
||||
|
||||
return match_args(
|
||||
self._args,
|
||||
error,
|
||||
((), lambda: list(self.filter_odata_data(self.get_items()))),
|
||||
((consts.rest.OVERVIEW,), lambda: list(self.get_items())),
|
||||
(
|
||||
(consts.rest.TABLEINFO,),
|
||||
lambda: self.TABLE.as_dict(),
|
||||
),
|
||||
((consts.rest.GUI, '<report>'), report_gui),
|
||||
)
|
||||
|
||||
def put(self) -> typing.Any:
|
||||
"""
|
||||
Processes a PUT request
|
||||
"""
|
||||
logger.debug(
|
||||
'method PUT for %s, %s, %s',
|
||||
self.__class__.__name__,
|
||||
self._args,
|
||||
self._params,
|
||||
)
|
||||
|
||||
if len(self._args) != 1:
|
||||
raise exceptions.rest.RequestError('Invalid report uuid!')
|
||||
|
||||
report = self._locate_report(self._args[0], self._params)
|
||||
|
||||
try:
|
||||
logger.debug('Report: %s', report)
|
||||
result = report.generate_encoded()
|
||||
|
||||
data = {
|
||||
'mime_type': report.mime_type,
|
||||
'encoded': report.encoded,
|
||||
'filename': report.filename,
|
||||
'data': result,
|
||||
}
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.exception('Generating report')
|
||||
raise exceptions.rest.RequestError(str(e)) from e
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
report = self._locate_report(for_type)
|
||||
return sorted(report.gui_description(), key=lambda f: f.gui.order)
|
||||
|
||||
# Returns the list of
|
||||
def get_items(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Generator[ReportItem, None, None]:
|
||||
for i in reports.available_reports:
|
||||
yield ReportItem(
|
||||
id=i.get_uuid(),
|
||||
mime_type=i.mime_type,
|
||||
encoded=i.encoded,
|
||||
group=i.translated_group(),
|
||||
name=i.translated_name(),
|
||||
description=i.translated_description(),
|
||||
)
|
||||
@@ -1,231 +0,0 @@
|
||||
# -*- 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 logging
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from uds import models
|
||||
from uds.core import consts, types
|
||||
from uds.core.exceptions import rest as rest_exceptions
|
||||
from uds.core.util import decorators, validators, model
|
||||
from uds.REST import Handler
|
||||
from uds.REST.utils import rest_result
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# REST API for Server Token Clients interaction
|
||||
# Register is split in two because tunnel registration also uses this
|
||||
class ServerRegisterBase(Handler):
|
||||
def post(self) -> dict[str, typing.Any]:
|
||||
server_token: models.Server
|
||||
now = model.sql_now()
|
||||
ip = self._params.get('ip', self.request.ip)
|
||||
if ':' in ip:
|
||||
# If zone is present, remove it
|
||||
ip = ip.split('%')[0]
|
||||
port = self._params.get('port', consts.net.SERVER_DEFAULT_LISTEN_PORT)
|
||||
|
||||
mac = self._params.get('mac', consts.NULL_MAC)
|
||||
data = self._params.get('data', None)
|
||||
subtype = self._params.get('subtype', '')
|
||||
os = self._params.get('os', types.os.KnownOS.UNKNOWN.os_name()).lower()
|
||||
certificate = self._params.get('certificate', '')
|
||||
version = self._params.get('version', '')
|
||||
|
||||
type = self._params['type'] # MUST be present
|
||||
hostname = self._params['hostname'] # MUST be present
|
||||
# Validate parameters
|
||||
try:
|
||||
try:
|
||||
types.servers.ServerType(type) # try to convert
|
||||
except ValueError:
|
||||
raise ValueError(_('Invalid type. Type must be an integer.'))
|
||||
if len(subtype) > 16:
|
||||
raise ValueError(_('Invalid subtype. Max length is 16.'))
|
||||
if len(os) > 16:
|
||||
raise ValueError(_('Invalid os. Max length is 16.'))
|
||||
if data and len(data) > 2048:
|
||||
raise ValueError(_('Invalid data. Max length is 2048.'))
|
||||
if port < 1 or port > 65535:
|
||||
raise ValueError(_('Invalid port. Must be between 1 and 65535'))
|
||||
validators.validate_ip(ip) # Will raise "validation error"
|
||||
validators.validate_fqdn(hostname)
|
||||
validators.validate_mac(mac)
|
||||
validators.validate_json(data)
|
||||
if certificate: # Emtpy certificate is allowed
|
||||
validators.validate_certificate(certificate)
|
||||
except Exception as e:
|
||||
raise rest_exceptions.RequestError(str(e)) from e
|
||||
|
||||
try:
|
||||
# If already exists a token for this, return it instead of creating a new one, and update the information...
|
||||
# Note that if the same IP (validated by a login) requests a new token, the old one will be sent instead of creating a new one
|
||||
# Note that we use IP (with type) to identify the server, so if any of them changes, a new token will be created
|
||||
# MAC is just informative, and data is used to store any other information that may be needed
|
||||
server_tokens = models.Server.objects.filter(hostname=hostname, type=type)
|
||||
if server_tokens.count() > 1:
|
||||
return rest_result('error', error='More than one server with same hostname and type')
|
||||
if server_tokens.count() == 0:
|
||||
raise models.Server.DoesNotExist() # Force creation of a new one
|
||||
server_token = server_tokens[0]
|
||||
# Update parameters
|
||||
# serverToken.hostname = self._params['hostname']
|
||||
server_token.register_username = self._user.pretty_name
|
||||
server_token.certificate = certificate
|
||||
# Ensure we do not store zone if IPv6 and present
|
||||
server_token.register_ip = self._request.ip.split('%')[0]
|
||||
server_token.listen_port = port
|
||||
server_token.ip = ip
|
||||
server_token.stamp = now
|
||||
server_token.mac = mac
|
||||
server_token.subtype = subtype # Optional
|
||||
server_token.version = version
|
||||
server_token.data = data
|
||||
server_token.save()
|
||||
except Exception:
|
||||
try:
|
||||
server_token = models.Server.objects.create(
|
||||
register_username=self._user.pretty_name,
|
||||
register_ip=self._request.ip.split('%')[0], # Ensure we do not store zone if IPv6 and present
|
||||
ip=ip,
|
||||
listen_port=port,
|
||||
hostname=self._params['hostname'],
|
||||
certificate=certificate,
|
||||
log_level=self._params.get('log_level', types.log.LogLevel.INFO.value),
|
||||
stamp=now,
|
||||
type=self._params['type'],
|
||||
subtype=self._params.get('subtype', ''), # Optional
|
||||
os_type=typing.cast(str, (self._params.get('os') or types.os.KnownOS.UNKNOWN.os_name())).lower(),
|
||||
mac=mac,
|
||||
data=data,
|
||||
version = version
|
||||
)
|
||||
except Exception as e:
|
||||
return rest_result('error', error=str(e))
|
||||
return rest_result(result=server_token.token)
|
||||
|
||||
|
||||
class ServerRegister(ServerRegisterBase):
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
PATH = 'servers'
|
||||
NAME = 'register'
|
||||
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
return types.rest.api.Components(schemas={
|
||||
'ServerRegisterItem': types.rest.api.Schema(
|
||||
type='object',
|
||||
description='A server object',
|
||||
properties={
|
||||
'id': types.rest.api.SchemaProperty(type='string'),
|
||||
'name': types.rest.api.SchemaProperty(type='string'),
|
||||
'ip': types.rest.api.SchemaProperty(type='string'),
|
||||
'port': types.rest.api.SchemaProperty(type='integer'),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
# REST handlers for server actions
|
||||
class ServerTest(Handler):
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
|
||||
PATH = 'servers'
|
||||
NAME = 'test'
|
||||
|
||||
@decorators.blocker()
|
||||
def post(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
# Test if a token is valid
|
||||
try:
|
||||
models.Server.objects.get(token=self._params['token'])
|
||||
return rest_result(True)
|
||||
except Exception as e:
|
||||
return rest_result('error', error=str(e))
|
||||
|
||||
|
||||
class ServerEvent(Handler):
|
||||
"""
|
||||
Manages a event notification from a server to UDS Broker
|
||||
|
||||
The idea behind this is manage events like login and logout from a single point
|
||||
|
||||
Currently possible notify actions are:
|
||||
* login
|
||||
* logout
|
||||
* log
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
PATH = 'servers'
|
||||
NAME = 'event'
|
||||
|
||||
def get_user_service(self) -> models.UserService:
|
||||
'''
|
||||
Looks for an userservice and, if not found, reraises DoesNotExist exception
|
||||
'''
|
||||
try:
|
||||
return models.UserService.objects.get(uuid=self._params['uuid'])
|
||||
except models.UserService.DoesNotExist:
|
||||
logger.error('User service not found (params: %s)', self._params)
|
||||
raise
|
||||
|
||||
@decorators.blocker()
|
||||
def post(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
# Avoid circular import
|
||||
from uds.core.managers.servers import ServerManager
|
||||
|
||||
try:
|
||||
server = models.Server.objects.get(token=self._params['token'])
|
||||
except models.Server.DoesNotExist:
|
||||
logger.error('Token error from %s (%s is an invalid token)', self._request.ip, self._params['token'])
|
||||
raise rest_exceptions.BlockAccess() from None # Block access if token is not valid
|
||||
except KeyError:
|
||||
raise rest_exceptions.RequestError('Token not present') from None # Invalid request if token is not present
|
||||
# Notify a server that a new service has been assigned to it
|
||||
# Get action from parameters
|
||||
# Parameters:
|
||||
# * event
|
||||
# * uuid (user service uuid)
|
||||
# * data: data related to the received event
|
||||
# * Login: { 'username': 'username'}
|
||||
# * Logout: { 'username': 'username'}
|
||||
# * Log: { 'level': 'level', 'message': 'message'}
|
||||
try:
|
||||
return ServerManager.manager().process_event(server, self._params)
|
||||
except Exception as e:
|
||||
logger.error('Error processing event %s: %s', self._params, e)
|
||||
return rest_result('error', error='Error processing event')
|
||||
@@ -1,549 +0,0 @@
|
||||
# -*- 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 as_server_item(self, item: 'models.Server') -> ServerItem:
|
||||
return ServerItem(
|
||||
id=item.uuid,
|
||||
hostname=item.hostname,
|
||||
ip=item.ip,
|
||||
listen_port=item.listen_port,
|
||||
mac=item.mac if item.mac != consts.NULL_MAC else '',
|
||||
maintenance_mode=item.maintenance_mode,
|
||||
register_username=item.register_username,
|
||||
stamp=item.stamp,
|
||||
)
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult[ServerItem]:
|
||||
parent = typing.cast('models.ServerGroup', parent) # We will receive for sure
|
||||
return [self.as_server_item(i) for i in self.filter_odata_queryset(parent.servers.all())]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> ServerItem:
|
||||
parent = typing.cast('models.ServerGroup', parent) # We will receive for sure
|
||||
return self.as_server_item(parent.servers.get(uuid=process_uuid(item)))
|
||||
|
||||
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())
|
||||
]
|
||||
@@ -1,401 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
from django.db import IntegrityError
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds import models
|
||||
|
||||
from uds.core import exceptions, types, module, services
|
||||
import uds.core.types.permissions
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.util import log, permissions, ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.consts.images import DEFAULT_THUMB_BASE64
|
||||
from uds.core import ui
|
||||
from uds.core.types.states import State
|
||||
|
||||
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ServiceItem(types.rest.ManagedObjectItem['models.Service']):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
comments: str
|
||||
deployed_services_count: int
|
||||
user_services_count: int
|
||||
max_services_count_type: str
|
||||
maintenance_mode: bool
|
||||
permission: int
|
||||
info: 'ServiceInfo|types.rest.NotRequired' = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ServiceInfo(types.rest.BaseRestItem):
|
||||
icon: str
|
||||
needs_publication: bool
|
||||
max_deployed: int
|
||||
uses_cache: bool
|
||||
uses_cache_l2: bool
|
||||
cache_tooltip: str
|
||||
cache_tooltip_l2: str
|
||||
needs_osmanager: bool
|
||||
allowed_protocols: list[str]
|
||||
services_type_provided: str
|
||||
can_reset: bool
|
||||
can_list_assignables: bool
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ServicePoolResumeItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
thumb: str
|
||||
user_services_count: int
|
||||
state: str
|
||||
|
||||
|
||||
class Services(DetailHandler[ServiceItem]): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Detail handler for Services, whose parent is a Provider
|
||||
"""
|
||||
|
||||
CUSTOM_METHODS = ['servicepools']
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def service_info(item: models.Service) -> ServiceInfo:
|
||||
info = item.get_type()
|
||||
overrided_fields = info.overrided_pools_fields or {}
|
||||
|
||||
return ServiceInfo(
|
||||
icon=info.icon64().replace('\n', ''),
|
||||
needs_publication=info.publication_type is not None,
|
||||
max_deployed=info.userservices_limit,
|
||||
uses_cache=info.uses_cache and overrided_fields.get('uses_cache', True),
|
||||
uses_cache_l2=info.uses_cache_l2,
|
||||
cache_tooltip=_(info.cache_tooltip),
|
||||
cache_tooltip_l2=_(info.cache_tooltip_l2),
|
||||
needs_osmanager=info.needs_osmanager,
|
||||
allowed_protocols=[str(i) for i in info.allowed_protocols],
|
||||
services_type_provided=info.services_type_provided,
|
||||
can_reset=info.can_reset,
|
||||
can_list_assignables=info.can_assign(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def service_item(item: models.Service, perm: int, full: bool = False) -> ServiceItem:
|
||||
"""
|
||||
Convert a service db item to a dict for a rest response
|
||||
:param item: Service item (db)
|
||||
:param full: If full is requested, add "extra" fields to complete information
|
||||
"""
|
||||
ret_value = ServiceItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
deployed_services_count=item.deployedServices.count(),
|
||||
user_services_count=models.UserService.objects.filter(deployed_service__service=item)
|
||||
.exclude(state__in=State.INFO_STATES)
|
||||
.count(),
|
||||
max_services_count_type=str(item.max_services_count_type),
|
||||
maintenance_mode=item.provider.maintenance_mode,
|
||||
permission=perm,
|
||||
item=item,
|
||||
)
|
||||
|
||||
if full:
|
||||
ret_value.info = Services.service_info(item)
|
||||
|
||||
return ret_value
|
||||
|
||||
def get_item_position(self, parent: Model, item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
return self.calc_item_position(item_uuid, parent.services.all())
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult[ServiceItem]:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
# Check what kind of access do we have to parent provider
|
||||
perm = permissions.effective_permissions(self._user, parent)
|
||||
return [Services.service_item(k, perm) for k in self.odata_filter(parent.services.all())]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> ServiceItem:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
# Check what kind of access do we have to parent provider
|
||||
return Services.service_item(
|
||||
parent.services.get(uuid=process_uuid(item)),
|
||||
permissions.effective_permissions(self._user, parent),
|
||||
full=True,
|
||||
)
|
||||
|
||||
def _delete_incomplete_service(self, service: models.Service) -> None:
|
||||
"""
|
||||
Deletes a service if it is needed to (that is, if it is not None) and silently catch any exception of this operation
|
||||
:param service: Service to delete (may be None, in which case it does nothing)
|
||||
"""
|
||||
try:
|
||||
service.delete()
|
||||
except Exception: # nosec: This is a delete, we don't care about exceptions
|
||||
pass
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> ServiceItem:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
# Extract item db fields
|
||||
# We need this fields for all
|
||||
logger.debug('Saving service for %s / %s', parent, item)
|
||||
|
||||
# Get the sevice type as first step, to obtain "overrided_fields" and other info
|
||||
service_type = parent.get_instance().get_service_by_type(self._params['data_type'])
|
||||
if not service_type:
|
||||
raise exceptions.rest.RequestError('Service type not found')
|
||||
|
||||
fields = self.fields_from_params(
|
||||
['name', 'comments', 'data_type', 'tags', 'max_services_count_type'],
|
||||
defaults=service_type.overrided_fields,
|
||||
)
|
||||
# Fix max_services_count_type to ServicesCountingType enum or ServicesCountingType.STANDARD if not found
|
||||
try:
|
||||
fields['max_services_count_type'] = types.services.ServicesCountingType.from_int(
|
||||
int(fields['max_services_count_type'])
|
||||
)
|
||||
except Exception:
|
||||
fields['max_services_count_type'] = types.services.ServicesCountingType.STANDARD
|
||||
tags = fields['tags']
|
||||
del fields['tags']
|
||||
service: typing.Optional[models.Service] = None
|
||||
|
||||
try:
|
||||
if not item: # Create new
|
||||
service = parent.services.create(**fields)
|
||||
else:
|
||||
service = parent.services.get(uuid=process_uuid(item))
|
||||
service.__dict__.update(fields)
|
||||
|
||||
if not service:
|
||||
raise Exception('Cannot create service!')
|
||||
|
||||
service.tags.set([models.Tag.objects.get_or_create(tag=val)[0] for val in tags])
|
||||
|
||||
service_instance = service.get_instance(self._params)
|
||||
|
||||
# Store token if this service provides one
|
||||
service.token = service_instance.get_token() or None # If '', use "None" to
|
||||
|
||||
# This may launch an validation exception (the get_instance(...) part)
|
||||
service.data = service_instance.serialize()
|
||||
|
||||
service.save()
|
||||
return Services.service_item(
|
||||
service, permissions.effective_permissions(self._user, service), full=True
|
||||
)
|
||||
|
||||
except models.Service.DoesNotExist:
|
||||
raise exceptions.rest.NotFound('Service not found') from None
|
||||
except IntegrityError as e: # Duplicate key probably
|
||||
if service and service.token and not item:
|
||||
service.delete()
|
||||
raise exceptions.rest.RequestError(
|
||||
'Service token seems to be in use by other service. Please, select a new one.'
|
||||
) from e
|
||||
raise exceptions.rest.RequestError('Element already exists (duplicate key error)') from e
|
||||
except exceptions.ui.ValidationError as e:
|
||||
if (
|
||||
not item and service
|
||||
): # Only remove partially saved element if creating new (if editing, ignore this)
|
||||
self._delete_incomplete_service(service)
|
||||
raise exceptions.rest.ValidationError('Input error: {0}'.format(e)) from e
|
||||
except Exception as e:
|
||||
if not item and service:
|
||||
self._delete_incomplete_service(service)
|
||||
logger.exception('Saving Service')
|
||||
raise exceptions.rest.RequestError('incorrect invocation to PUT: {0}'.format(e)) from e
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
try:
|
||||
service = parent.services.get(uuid=process_uuid(item))
|
||||
if service.deployedServices.count() == 0:
|
||||
service.delete()
|
||||
return
|
||||
except models.Service.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Service not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error deleting service %s from %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error deleting service')) from None
|
||||
|
||||
raise exceptions.rest.RequestError('Item has associated deployed services')
|
||||
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Services of {0}').format(parent.name))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='deployed_services_count', title=_('Services Pools'), width='12em')
|
||||
.numeric_column(name='user_services_count', title=_('User Services'), width='12em')
|
||||
.dict_column(
|
||||
name='max_services_count_type',
|
||||
title=_('Counting method'),
|
||||
dct={
|
||||
types.services.ServicesCountingType.STANDARD: _('Standard'),
|
||||
types.services.ServicesCountingType.CONSERVATIVE: _('Conservative'),
|
||||
},
|
||||
)
|
||||
.text_column(name='tags', title=_('Tags'), visible=False)
|
||||
.row_style(prefix='row-maintenance-', field='maintenance_mode')
|
||||
.build()
|
||||
)
|
||||
|
||||
def enum_types(self, parent: 'Model', for_type: typing.Optional[str]) -> list[types.rest.TypeInfo]:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
logger.debug('get_types parameters: %s, %s', parent, for_type)
|
||||
offers: list[types.rest.TypeInfo] = []
|
||||
if for_type is None:
|
||||
offers = [type(self).as_typeinfo(t) for t in parent.get_type().get_provided_services()]
|
||||
else:
|
||||
for t in parent.get_type().get_provided_services():
|
||||
if for_type == t.mod_type():
|
||||
offers = [type(self).as_typeinfo(t)]
|
||||
break
|
||||
if not offers:
|
||||
raise exceptions.rest.NotFound('type not found')
|
||||
|
||||
return offers
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
|
||||
"""
|
||||
If the detail has any possible types, provide them overriding this method
|
||||
:param cls:
|
||||
"""
|
||||
for parent_type in services.factory().providers().values():
|
||||
for service in parent_type.get_provided_services():
|
||||
yield service
|
||||
|
||||
def get_gui(self, parent: 'Model', for_type: str) -> list[types.ui.GuiElement]:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
try:
|
||||
logger.debug('getGui parameters: %s, %s', parent, for_type)
|
||||
parent_instance = parent.get_instance()
|
||||
service_type = parent_instance.get_service_by_type(for_type)
|
||||
if not service_type:
|
||||
raise exceptions.rest.RequestError(f'Gui for type "{for_type}" not found')
|
||||
with Environment.temporary_environment() as env:
|
||||
service = service_type(
|
||||
env, parent_instance
|
||||
) # Instantiate it so it has the opportunity to alter gui description based on parent
|
||||
overrided_fields = service.overrided_fields or {}
|
||||
|
||||
gui = (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_choice(
|
||||
name='max_services_count_type',
|
||||
choices=[
|
||||
ui.gui.choice_item(
|
||||
str(types.services.ServicesCountingType.STANDARD.value), _('Standard')
|
||||
),
|
||||
ui.gui.choice_item(
|
||||
str(types.services.ServicesCountingType.CONSERVATIVE.value), _('Conservative')
|
||||
),
|
||||
],
|
||||
label=_('Service counting method'),
|
||||
tooltip=_('Kind of service counting for calculating if MAX is reached'),
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
)
|
||||
.add_fields(service.gui_description())
|
||||
)
|
||||
|
||||
return [field_gui for field_gui in gui.build() if field_gui.name not in overrided_fields]
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('get_gui')
|
||||
raise exceptions.rest.ResponseError(str(e)) from e
|
||||
|
||||
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
try:
|
||||
service = parent.services.get(uuid=process_uuid(item))
|
||||
logger.debug('Getting logs for %s', item)
|
||||
return log.get_logs(service)
|
||||
except models.Service.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Service not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting logs for %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting logs')) from None
|
||||
|
||||
def servicepools(self, parent: 'Model', item: str) -> list[ServicePoolResumeItem]:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
service = parent.services.get(uuid=process_uuid(item))
|
||||
logger.debug('Got parameters for servicepools: %s, %s', parent, item)
|
||||
res: list[ServicePoolResumeItem] = []
|
||||
for i in service.deployedServices.all():
|
||||
try:
|
||||
self.check_access(
|
||||
i, uds.core.types.permissions.PermissionType.READ
|
||||
) # Ensures access before listing...
|
||||
res.append(
|
||||
ServicePoolResumeItem(
|
||||
id=i.uuid,
|
||||
name=i.name,
|
||||
thumb=i.image.thumb64 if i.image is not None else DEFAULT_THUMB_BASE64,
|
||||
user_services_count=i.userServices.exclude(
|
||||
state__in=(State.REMOVED, State.ERROR)
|
||||
).count(),
|
||||
state=_('With errors') if i.is_restrained() else _('Ok'),
|
||||
)
|
||||
)
|
||||
except exceptions.rest.AccessDenied:
|
||||
pass
|
||||
|
||||
return res
|
||||
@@ -1,123 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import types
|
||||
from uds.core.util import ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.models import Image, ServicePoolGroup
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ServicePoolGroupItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
comments: str
|
||||
priority: int
|
||||
image_id: str | None | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
thumb: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
class ServicesPoolGroups(ModelHandler[ServicePoolGroupItem]):
|
||||
|
||||
PATH = 'gallery'
|
||||
MODEL = ServicePoolGroup
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'image_id', 'priority']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Services Pool Groups'))
|
||||
.numeric_column(name='priority', title=_('Priority'), width='6em')
|
||||
.image(name='thumb', title=_('Image'), width='96px')
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.build()
|
||||
)
|
||||
|
||||
# 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 pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
img_id = fields['image_id']
|
||||
fields['image_id'] = None
|
||||
logger.debug('Image id: %s', img_id)
|
||||
try:
|
||||
if img_id != '-1':
|
||||
image = Image.objects.get(uuid=process_uuid(img_id))
|
||||
fields['image_id'] = image.id
|
||||
except Exception:
|
||||
logger.exception('At image recovering')
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, for_type: str) -> list[typing.Any]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.PRIORITY)
|
||||
.new_tab(types.ui.Tab.DISPLAY)
|
||||
.add_image_choice()
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_item(self, item: 'Model') -> ServicePoolGroupItem:
|
||||
item = ensure.is_instance(item, ServicePoolGroup)
|
||||
return ServicePoolGroupItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
comments=item.comments,
|
||||
priority=item.priority,
|
||||
image_id=item.image.uuid if item.image else None,
|
||||
)
|
||||
|
||||
def get_item_summary(self, item: 'Model') -> ServicePoolGroupItem:
|
||||
item = ensure.is_instance(item, ServicePoolGroup)
|
||||
return ServicePoolGroupItem(
|
||||
id=item.uuid,
|
||||
priority=item.priority,
|
||||
name=item.name,
|
||||
comments=item.comments,
|
||||
thumb=item.thumb64,
|
||||
)
|
||||
@@ -1,714 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-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, Count, Q
|
||||
|
||||
from uds.core import types, exceptions, consts
|
||||
from uds.core.managers.userservice import UserServiceManager
|
||||
from uds.core import ui
|
||||
from uds.core.consts.images import DEFAULT_THUMB_BASE64
|
||||
from uds.core.util import log, permissions, ensure, ui as ui_utils
|
||||
from uds.core.util.config import GlobalConfig
|
||||
from uds.core.util.model import sql_now, process_uuid
|
||||
from uds.core.types.states import State
|
||||
from uds.models import Account, Image, OSManager, Service, ServicePool, ServicePoolGroup, User
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
from .op_calendars import AccessCalendars, ActionsCalendars
|
||||
from .services import Services, ServiceInfo
|
||||
from .user_services import AssignedUserService, CachedService, Changelog, Groups, Publications, Transports
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ServicePoolItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
short_name: str
|
||||
tags: typing.List[str]
|
||||
parent: str
|
||||
parent_type: str
|
||||
comments: str
|
||||
state: str
|
||||
thumb: str
|
||||
account: str
|
||||
account_id: str | None
|
||||
service_id: str
|
||||
provider_id: str
|
||||
image_id: str | None
|
||||
initial_srvs: int
|
||||
cache_l1_srvs: int
|
||||
cache_l2_srvs: int
|
||||
max_srvs: int
|
||||
show_transports: bool
|
||||
visible: bool
|
||||
allow_users_remove: bool
|
||||
allow_users_reset: bool
|
||||
ignores_unused: bool
|
||||
fallbackAccess: str
|
||||
meta_member: list[dict[str, str]]
|
||||
calendar_message: str
|
||||
custom_message: str
|
||||
display_custom_message: bool
|
||||
osmanager_id: str | None
|
||||
|
||||
user_services_count: int | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
user_services_in_preparation: int | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
restrained: bool | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
permission: types.permissions.PermissionType | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
info: ServiceInfo | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
pool_group_id: str | None | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
pool_group_name: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
pool_group_thumb: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
usage: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
class ServicesPools(ModelHandler[ServicePoolItem]):
|
||||
"""
|
||||
Handles Services Pools REST requests
|
||||
"""
|
||||
|
||||
MODEL = ServicePool
|
||||
DETAIL = {
|
||||
'services': AssignedUserService,
|
||||
'cache': CachedService,
|
||||
'servers': CachedService, # Alias for cache, but will change in a future release
|
||||
'groups': Groups,
|
||||
'transports': Transports,
|
||||
'publications': Publications,
|
||||
'changelog': Changelog,
|
||||
'access': AccessCalendars,
|
||||
'actions': ActionsCalendars,
|
||||
}
|
||||
|
||||
FIELDS_TO_SAVE = [
|
||||
'name',
|
||||
'short_name',
|
||||
'comments',
|
||||
'tags',
|
||||
'service_id',
|
||||
'osmanager_id',
|
||||
'image_id',
|
||||
'pool_group_id',
|
||||
'initial_srvs',
|
||||
'cache_l1_srvs',
|
||||
'cache_l2_srvs',
|
||||
'max_srvs',
|
||||
'show_transports',
|
||||
'visible',
|
||||
'allow_users_remove',
|
||||
'allow_users_reset',
|
||||
'ignores_unused',
|
||||
'account_id',
|
||||
'calendar_message',
|
||||
'custom_message',
|
||||
'display_custom_message',
|
||||
'state:_', # Optional field, defaults to Nothing (to apply default or existing value)
|
||||
]
|
||||
|
||||
EXCLUDED_FIELDS = ['osmanager_id', 'service_id']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Service Pools'))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.dict_column(name='state', title=_('Status'), dct=State.literals_dict())
|
||||
.numeric_column(name='user_services_count', title=_('User services'))
|
||||
.numeric_column(name='user_services_in_preparation', title=_('In Preparation'))
|
||||
.text_column(name='usage', title=_('Usage'))
|
||||
.boolean(name='visible', title=_('Visible'))
|
||||
.boolean(name='show_transports', title=_('Shows transports'))
|
||||
.text_column(name='pool_group_name', title=_('Pool group'))
|
||||
.text_column(name='parent', title=_('Parent service'))
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.with_filter_fields('name', 'state')
|
||||
.build()
|
||||
)
|
||||
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('set_fallback_access', True),
|
||||
types.rest.ModelCustomMethod('get_fallback_access', True),
|
||||
types.rest.ModelCustomMethod('actions_list', True),
|
||||
types.rest.ModelCustomMethod('list_assignables', True),
|
||||
types.rest.ModelCustomMethod('create_from_assignable', True),
|
||||
types.rest.ModelCustomMethod('add_log', True),
|
||||
]
|
||||
|
||||
# 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 apply_sort(self, qs: 'QuerySet[typing.Any]') -> 'list[typing.Any] | QuerySet[typing.Any]':
|
||||
if field_info := self.get_sort_field_info('state'):
|
||||
field_name, is_descending = field_info
|
||||
order_by_field = f"-{field_name}" if is_descending else field_name
|
||||
return qs.order_by(order_by_field)
|
||||
|
||||
return super().apply_sort(qs)
|
||||
|
||||
def get_items(
|
||||
self, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Generator[ServicePoolItem, None, None]:
|
||||
# Optimized query, due that there is a lot of info needed for theee
|
||||
d = sql_now() - datetime.timedelta(seconds=GlobalConfig.RESTRAINT_TIME.as_int())
|
||||
return super().get_items(
|
||||
sumarize=kwargs.get('overview', True),
|
||||
query=(
|
||||
ServicePool.objects.prefetch_related(
|
||||
'service',
|
||||
'service__provider',
|
||||
'servicesPoolGroup',
|
||||
'servicesPoolGroup__image',
|
||||
'osmanager',
|
||||
'image',
|
||||
'tags',
|
||||
'memberOfMeta__meta_pool',
|
||||
'account',
|
||||
)
|
||||
.annotate(
|
||||
valid_count=Count(
|
||||
'userServices',
|
||||
filter=~Q(userServices__state__in=State.INFO_STATES),
|
||||
)
|
||||
)
|
||||
.annotate(preparing_count=Count('userServices', filter=Q(userServices__state=State.PREPARING)))
|
||||
.annotate(
|
||||
error_count=Count(
|
||||
'userServices',
|
||||
filter=Q(
|
||||
userServices__state=State.ERROR,
|
||||
userServices__state_date__gt=d,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
usage_count=Count(
|
||||
'userServices',
|
||||
filter=Q(
|
||||
userServices__state__in=State.VALID_STATES,
|
||||
userServices__cache_level=0,
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
# return super().get_items(overview=kwargs.get('overview', True), prefetch=['service', 'service__provider', 'servicesPoolGroup', 'image', 'tags'])
|
||||
# return super(ServicesPools, self).get_items(*args, **kwargs)
|
||||
|
||||
def get_item(self, item: 'Model') -> ServicePoolItem:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
summary = 'summarize' in self._params
|
||||
# if item does not have an associated service, hide it (the case, for example, for a removed service)
|
||||
# Access from dict will raise an exception, and item will be skipped
|
||||
|
||||
poolgroup_id: typing.Optional[str] = None
|
||||
poolgroup_name: str = _('Default')
|
||||
poolgroup_thumb: str = DEFAULT_THUMB_BASE64
|
||||
if item.servicesPoolGroup:
|
||||
poolgroup_id = item.servicesPoolGroup.uuid
|
||||
poolgroup_name = item.servicesPoolGroup.name
|
||||
if item.servicesPoolGroup.image:
|
||||
poolgroup_thumb = item.servicesPoolGroup.image.thumb64
|
||||
|
||||
state = item.state
|
||||
if item.is_in_maintenance():
|
||||
state = State.MAINTENANCE
|
||||
# This needs a lot of queries, and really does not apport anything important to the report
|
||||
# elif UserServiceManager.manager().canInitiateServiceFromDeployedService(item) is False:
|
||||
# state = State.SLOWED_DOWN
|
||||
val: ServicePoolItem = ServicePoolItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
short_name=item.short_name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
parent=item.service.name,
|
||||
parent_type=item.service.data_type,
|
||||
comments=item.comments,
|
||||
state=state,
|
||||
thumb=item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
|
||||
account=item.account.name if item.account is not None else '',
|
||||
account_id=item.account.uuid if item.account is not None else None,
|
||||
service_id=item.service.uuid,
|
||||
provider_id=item.service.provider.uuid,
|
||||
image_id=item.image.uuid if item.image is not None else None,
|
||||
initial_srvs=item.initial_srvs,
|
||||
cache_l1_srvs=item.cache_l1_srvs,
|
||||
cache_l2_srvs=item.cache_l2_srvs,
|
||||
max_srvs=item.max_srvs,
|
||||
show_transports=item.show_transports,
|
||||
visible=item.visible,
|
||||
allow_users_remove=item.allow_users_remove,
|
||||
allow_users_reset=item.allow_users_reset,
|
||||
ignores_unused=item.ignores_unused,
|
||||
fallbackAccess=item.fallbackAccess,
|
||||
meta_member=[{'id': i.meta_pool.uuid, 'name': i.meta_pool.name} for i in item.memberOfMeta.all()],
|
||||
calendar_message=item.calendar_message,
|
||||
custom_message=item.custom_message,
|
||||
display_custom_message=item.display_custom_message,
|
||||
osmanager_id=item.osmanager.uuid if item.osmanager else None,
|
||||
)
|
||||
if summary:
|
||||
return val
|
||||
|
||||
if hasattr(item, 'valid_count'):
|
||||
valid_count = getattr(item, 'valid_count')
|
||||
preparing_count = getattr(item, 'preparing_count')
|
||||
restrained = getattr(item, 'error_count') >= GlobalConfig.RESTRAINT_COUNT.as_int()
|
||||
usage_count = getattr(item, 'usage_count')
|
||||
else:
|
||||
valid_count = item.userServices.exclude(state__in=State.INFO_STATES).count()
|
||||
preparing_count = item.userServices.filter(state=State.PREPARING).count()
|
||||
restrained = item.is_restrained()
|
||||
usage_count = -1
|
||||
|
||||
poolgroup_id = None
|
||||
poolgroup_name = _('Default')
|
||||
poolgroup_thumb = DEFAULT_THUMB_BASE64
|
||||
if item.servicesPoolGroup is not None:
|
||||
poolgroup_id = item.servicesPoolGroup.uuid
|
||||
poolgroup_name = item.servicesPoolGroup.name
|
||||
if item.servicesPoolGroup.image is not None:
|
||||
poolgroup_thumb = item.servicesPoolGroup.image.thumb64
|
||||
|
||||
val.thumb = item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64
|
||||
val.user_services_count = valid_count
|
||||
val.user_services_in_preparation = preparing_count
|
||||
val.tags = [tag.tag for tag in item.tags.all()]
|
||||
val.restrained = restrained
|
||||
val.permission = permissions.effective_permissions(self._user, item)
|
||||
val.info = Services.service_info(item.service)
|
||||
val.pool_group_id = poolgroup_id
|
||||
val.pool_group_name = poolgroup_name
|
||||
val.pool_group_thumb = poolgroup_thumb
|
||||
val.usage = str(item.usage(usage_count).percent) + '%'
|
||||
|
||||
return val
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
# if OSManager.objects.count() < 1: # No os managers, can't create db
|
||||
# raise exceptions.rest.ResponseError(gettext('Create at least one OS Manager before creating a new service pool'))
|
||||
if Service.objects.count() < 1:
|
||||
raise exceptions.rest.ResponseError(
|
||||
gettext('Create at least a service before creating a new service pool')
|
||||
)
|
||||
|
||||
gui = (
|
||||
(
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
)
|
||||
.set_order(-95)
|
||||
.add_text(
|
||||
name='short_name',
|
||||
label=gettext('Short name'),
|
||||
tooltip=gettext('Short name for user service visualization'),
|
||||
length=32,
|
||||
)
|
||||
.set_order(100)
|
||||
.add_choice(
|
||||
name='service_id',
|
||||
choices=[ui.gui.choice_item('', '')]
|
||||
+ ui.gui.sorted_choices(
|
||||
[ui.gui.choice_item(v.uuid, v.provider.name + '\\' + v.name) for v in Service.objects.all()]
|
||||
),
|
||||
label=gettext('Base service'),
|
||||
tooltip=gettext('Service used as base of this service pool'),
|
||||
readonly=True,
|
||||
)
|
||||
.add_choice(
|
||||
name='osmanager_id',
|
||||
choices=[ui.gui.choice_item(-1, '')]
|
||||
+ ui.gui.sorted_choices([ui.gui.choice_item(v.uuid, v.name) for v in OSManager.objects.all()]),
|
||||
label=gettext('OS Manager'),
|
||||
tooltip=gettext('OS Manager used as base of this service pool'),
|
||||
readonly=True,
|
||||
)
|
||||
.add_checkbox(
|
||||
name='publish_on_save',
|
||||
default=True,
|
||||
label=gettext('Publish on save'),
|
||||
tooltip=gettext('If active, the service will be published when saved'),
|
||||
)
|
||||
.new_tab(types.ui.Tab.DISPLAY)
|
||||
.add_checkbox(
|
||||
name='visible',
|
||||
default=True,
|
||||
label=gettext('Visible'),
|
||||
tooltip=gettext('If active, transport will be visible for users'),
|
||||
)
|
||||
.add_image_choice()
|
||||
.add_image_choice(
|
||||
name='pool_group_id',
|
||||
choices=[
|
||||
ui.gui.choice_image(v.uuid, v.name, v.thumb64) for v in ServicePoolGroup.objects.all()
|
||||
],
|
||||
label=gettext('Pool group'),
|
||||
tooltip=gettext('Pool group for this pool (for pool classify on display)'),
|
||||
)
|
||||
.add_text(
|
||||
name='calendar_message',
|
||||
label=gettext('Calendar access denied text'),
|
||||
tooltip=gettext('Custom message to be shown to users if access is limited by calendar rules.'),
|
||||
)
|
||||
.add_text(
|
||||
name='custom_message',
|
||||
label=gettext('Custom launch message text'),
|
||||
tooltip=gettext(
|
||||
'Custom message to be shown to users, if active, when trying to start a service from this pool.'
|
||||
),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='display_custom_message',
|
||||
default=False,
|
||||
label=gettext('Enable custom launch message'),
|
||||
tooltip=gettext('If active, the custom launch message will be shown to users'),
|
||||
)
|
||||
.new_tab(gettext('Availability'))
|
||||
.add_numeric(
|
||||
name='initial_srvs',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Initial available services'),
|
||||
tooltip=gettext('Services created initially for this service pool'),
|
||||
)
|
||||
.add_numeric(
|
||||
name='cache_l1_srvs',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Services to keep in cache'),
|
||||
tooltip=gettext('Services kept in cache for improved user service assignation'),
|
||||
)
|
||||
.add_numeric(
|
||||
name='cache_l2_srvs',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Services to keep in L2 cache'),
|
||||
tooltip=gettext('Services kept in cache of level2 for improved service assignation'),
|
||||
)
|
||||
.add_numeric(
|
||||
name='max_srvs',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Max services per user'),
|
||||
tooltip=gettext('Maximum number of services that can be assigned to a user from this pool'),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='show_transports',
|
||||
default=False,
|
||||
label=gettext('Show transports'),
|
||||
tooltip=gettext('If active, transports will be shown to users'),
|
||||
)
|
||||
.new_tab(types.ui.Tab.ADVANCED)
|
||||
.add_checkbox(
|
||||
name='allow_users_remove',
|
||||
default=False,
|
||||
label=gettext('Allow removal by users'),
|
||||
tooltip=gettext(
|
||||
'If active, the user will be allowed to remove the service "manually". Be careful with this, because the user will have the "power" to delete its own service'
|
||||
),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='allow_users_reset',
|
||||
default=False,
|
||||
label=gettext('Allow reset by users'),
|
||||
tooltip=gettext('If active, the user will be allowed to reset the service'),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='ignores_unused',
|
||||
default=False,
|
||||
label=gettext('Ignores unused'),
|
||||
tooltip=gettext(
|
||||
'If the option is enabled, UDS will not attempt to detect and remove the user services assigned but not in use.'
|
||||
),
|
||||
)
|
||||
.add_choice(
|
||||
name='account_id',
|
||||
choices=[ui.gui.choice_item('', '')]
|
||||
+ ui.gui.sorted_choices([ui.gui.choice_item(v.uuid, v.name) for v in Account.objects.all()]),
|
||||
label=gettext('Account'),
|
||||
tooltip=gettext('Account used for this service pool'),
|
||||
readonly=True,
|
||||
)
|
||||
)
|
||||
return gui.build()
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
# logger.debug(self._params)
|
||||
|
||||
if types.pools.UsageInfoVars.processed_macros_len(fields['name']) > 128:
|
||||
raise exceptions.rest.RequestError(gettext('Name too long'))
|
||||
|
||||
if types.pools.UsageInfoVars.processed_macros_len(fields['short_name']) > 32:
|
||||
raise exceptions.rest.RequestError(gettext('Short name too long'))
|
||||
|
||||
try:
|
||||
try:
|
||||
service = Service.objects.get(uuid=process_uuid(fields['service_id']))
|
||||
fields['service_id'] = service.id
|
||||
except Exception:
|
||||
raise exceptions.rest.RequestError(gettext('Base service does not exist anymore')) from None
|
||||
|
||||
try:
|
||||
service_type = service.get_type()
|
||||
|
||||
if service_type.publication_type is None:
|
||||
self._params['publish_on_save'] = False
|
||||
|
||||
if service_type.can_reset is False:
|
||||
self._params['allow_users_reset'] = False
|
||||
|
||||
if service_type.needs_osmanager is True:
|
||||
try:
|
||||
osmanager = OSManager.objects.get(uuid=process_uuid(fields['osmanager_id']))
|
||||
fields['osmanager_id'] = osmanager.id
|
||||
except Exception:
|
||||
if fields.get('state') != State.LOCKED:
|
||||
raise exceptions.rest.RequestError(
|
||||
gettext('This service requires an OS Manager')
|
||||
) from None
|
||||
del fields['osmanager_id']
|
||||
else:
|
||||
del fields['osmanager_id']
|
||||
|
||||
# If service has "overrided fields", overwrite received ones now
|
||||
if service_type.overrided_pools_fields:
|
||||
for k, v in service_type.overrided_pools_fields.items():
|
||||
fields[k] = v
|
||||
|
||||
if service_type.uses_cache_l2 is False:
|
||||
fields['cache_l2_srvs'] = 0
|
||||
|
||||
if service_type.uses_cache is False:
|
||||
for k in (
|
||||
'initial_srvs',
|
||||
'cache_l1_srvs',
|
||||
'cache_l2_srvs',
|
||||
'max_srvs',
|
||||
):
|
||||
fields[k] = 0
|
||||
else: # uses cache, adjust values
|
||||
fields['max_srvs'] = int(fields['max_srvs']) or 1 # ensure max_srvs is at least 1
|
||||
fields['initial_srvs'] = int(fields['initial_srvs'])
|
||||
fields['cache_l1_srvs'] = int(fields['cache_l1_srvs'])
|
||||
|
||||
# if service_type.userservices_limit != consts.UNLIMITED:
|
||||
# fields['max_srvs'] = min((fields['max_srvs'], service_type.userservices_limit))
|
||||
# fields['initial_srvs'] = min(fields['initial_srvs'], service_type.userservices_limit)
|
||||
# fields['cache_l1_srvs'] = min(fields['cache_l1_srvs'], service_type.userservices_limit)
|
||||
except Exception as e:
|
||||
raise exceptions.rest.RequestError(gettext('This service requires an OS Manager')) from e
|
||||
|
||||
# If max < initial or cache_1 or cache_l2
|
||||
fields['max_srvs'] = max(
|
||||
(
|
||||
int(fields['initial_srvs']),
|
||||
int(fields['cache_l1_srvs']),
|
||||
int(fields['max_srvs']),
|
||||
)
|
||||
)
|
||||
|
||||
# *** ACCOUNT ***
|
||||
account_id = fields['account_id']
|
||||
fields['account_id'] = None
|
||||
logger.debug('Account id: %s', account_id)
|
||||
|
||||
if account_id != '-1':
|
||||
try:
|
||||
fields['account_id'] = Account.objects.get(uuid=process_uuid(account_id)).id
|
||||
except Exception:
|
||||
logger.exception('Getting account ID')
|
||||
|
||||
# **** IMAGE ***
|
||||
image_id = fields['image_id']
|
||||
fields['image_id'] = None
|
||||
logger.debug('Image id: %s', image_id)
|
||||
try:
|
||||
if image_id != '-1':
|
||||
image = Image.objects.get(uuid=process_uuid(image_id))
|
||||
fields['image_id'] = image.id
|
||||
except Exception:
|
||||
logger.exception('At image recovering')
|
||||
|
||||
# Servicepool Group
|
||||
pool_group_id = fields['pool_group_id']
|
||||
del fields['pool_group_id']
|
||||
fields['servicesPoolGroup_id'] = None
|
||||
logger.debug('pool_group_id: %s', pool_group_id)
|
||||
try:
|
||||
if pool_group_id != '-1':
|
||||
spgrp = ServicePoolGroup.objects.get(uuid=process_uuid(pool_group_id))
|
||||
fields['servicesPoolGroup_id'] = spgrp.id
|
||||
except Exception:
|
||||
logger.exception('At service pool group recovering')
|
||||
|
||||
except (exceptions.rest.RequestError, exceptions.rest.ResponseError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise exceptions.rest.RequestError(str(e)) from e
|
||||
|
||||
def post_save(self, item: 'Model') -> None:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
if self._params.get('publish_on_save', False) is True:
|
||||
try:
|
||||
item.publish()
|
||||
except Exception as e:
|
||||
logger.error('Could not publish service pool %s: %s', item.name, e)
|
||||
|
||||
def delete_item(self, item: 'Model') -> None:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
try:
|
||||
logger.debug('Deleting %s', item)
|
||||
item.remove() # This will mark it for deletion, but in fact will not delete it directly
|
||||
except Exception:
|
||||
# Eat it and logit
|
||||
logger.exception('deleting service pool')
|
||||
|
||||
# Logs
|
||||
def get_logs(self, item: 'Model') -> typing.Any:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
try:
|
||||
return log.get_logs(item)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# Set fallback status
|
||||
def set_fallback_access(self, item: 'Model') -> typing.Any:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
|
||||
|
||||
fallback = self._params.get('fallbackAccess', self.params.get('fallback', None))
|
||||
if fallback:
|
||||
logger.debug('Setting fallback of %s to %s', item.name, fallback)
|
||||
item.fallbackAccess = fallback
|
||||
item.save()
|
||||
return item.fallbackAccess
|
||||
|
||||
def get_fallback_access(self, item: 'Model') -> typing.Any:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
return item.fallbackAccess
|
||||
|
||||
# Returns the action list based on current element, for calendar
|
||||
def actions_list(self, item: 'Model') -> list[types.calendar.CalendarAction]:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
|
||||
# If item is locked, only allow publish
|
||||
if item.state == types.states.State.LOCKED:
|
||||
# Only allow publish
|
||||
return [
|
||||
consts.calendar.CALENDAR_ACTION_PUBLISH,
|
||||
]
|
||||
|
||||
valid_actions: list[types.calendar.CalendarAction] = []
|
||||
item_info = item.service.get_type()
|
||||
if item_info.uses_cache is True:
|
||||
valid_actions += [
|
||||
consts.calendar.CALENDAR_ACTION_INITIAL,
|
||||
consts.calendar.CALENDAR_ACTION_CACHE_L1,
|
||||
consts.calendar.CALENDAR_ACTION_MAX,
|
||||
]
|
||||
if item_info.uses_cache_l2 is True:
|
||||
valid_actions += [
|
||||
consts.calendar.CALENDAR_ACTION_CACHE_L2,
|
||||
]
|
||||
|
||||
if item_info.publication_type is not None:
|
||||
valid_actions += [
|
||||
consts.calendar.CALENDAR_ACTION_PUBLISH,
|
||||
]
|
||||
|
||||
# Transport & groups actions
|
||||
valid_actions += [
|
||||
consts.calendar.CALENDAR_ACTION_ADD_TRANSPORT,
|
||||
consts.calendar.CALENDAR_ACTION_DEL_TRANSPORT,
|
||||
consts.calendar.CALENDAR_ACTION_DEL_ALL_TRANSPORTS,
|
||||
consts.calendar.CALENDAR_ACTION_ADD_GROUP,
|
||||
consts.calendar.CALENDAR_ACTION_DEL_GROUP,
|
||||
consts.calendar.CALENDAR_ACTION_DEL_ALL_GROUPS,
|
||||
]
|
||||
|
||||
# Advanced actions
|
||||
valid_actions += [
|
||||
consts.calendar.CALENDAR_ACTION_IGNORE_UNUSED,
|
||||
consts.calendar.CALENDAR_ACTION_REMOVE_USERSERVICES,
|
||||
consts.calendar.CALENDAR_ACTION_REMOVE_STUCK_USERSERVICES,
|
||||
consts.calendar.CALENDAR_ACTION_DISPLAY_CUSTOM_MESSAGE,
|
||||
]
|
||||
return valid_actions
|
||||
|
||||
def list_assignables(self, item: 'Model') -> typing.Any:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
service = item.service.get_instance()
|
||||
return list(service.enumerate_assignables())
|
||||
|
||||
def create_from_assignable(self, item: 'Model') -> typing.Any:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
if 'user_id' not in self._params or 'assignable_id' not in self._params:
|
||||
raise exceptions.rest.RequestError('Invalid parameters')
|
||||
|
||||
logger.debug('Creating from assignable: %s', self._params)
|
||||
UserServiceManager.manager().create_from_assignable(
|
||||
item,
|
||||
User.objects.get(uuid__iexact=process_uuid(self._params['user_id'])),
|
||||
self._params['assignable_id'],
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def add_log(self, item: 'Model') -> typing.Any:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
if 'message' not in self._params:
|
||||
raise exceptions.rest.RequestError('Invalid parameters')
|
||||
if 'level' not in self._params:
|
||||
raise exceptions.rest.RequestError('Invalid parameters')
|
||||
|
||||
log.log(
|
||||
item,
|
||||
level=types.log.LogLevel.from_str(self._params['level']),
|
||||
message=self._params['message'],
|
||||
source=types.log.LogSource.REST,
|
||||
log_name=self._params.get('log_name', None),
|
||||
)
|
||||
@@ -1,178 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-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 logging
|
||||
import datetime
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import exceptions, types
|
||||
|
||||
from uds.models import UserService, Provider
|
||||
from uds.core.types.states import State
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.REST.model import DetailHandler
|
||||
from uds.core.util import ensure, ui as ui_utils
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ServicesUsageItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
state_date: datetime.datetime
|
||||
creation_date: datetime.datetime
|
||||
unique_id: str
|
||||
friendly_name: str
|
||||
owner: str
|
||||
owner_info: dict[str, str]
|
||||
service: str
|
||||
service_id: str
|
||||
pool: str
|
||||
pool_id: str
|
||||
ip: str
|
||||
source_host: str
|
||||
source_ip: str
|
||||
in_use: bool
|
||||
|
||||
|
||||
class ServicesUsage(DetailHandler[ServicesUsageItem]):
|
||||
"""
|
||||
Rest handler for Assigned Services, which parent is Service
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def item_as_dict(item: UserService) -> ServicesUsageItem:
|
||||
"""
|
||||
Converts an assigned/cached service db item to a dictionary for REST response
|
||||
:param item: item to convert
|
||||
:param is_cache: If item is from cache or not
|
||||
"""
|
||||
with item.properties as p:
|
||||
props = dict(p)
|
||||
|
||||
if item.user is None:
|
||||
owner = ''
|
||||
owner_info = {'auth_id': '', 'user_id': ''}
|
||||
else:
|
||||
owner = item.user.pretty_name
|
||||
owner_info = {'auth_id': item.user.manager.uuid, 'user_id': item.user.uuid}
|
||||
|
||||
return ServicesUsageItem(
|
||||
id=item.uuid,
|
||||
state_date=item.state_date,
|
||||
creation_date=item.creation_date,
|
||||
unique_id=item.unique_id,
|
||||
friendly_name=item.friendly_name,
|
||||
owner=owner,
|
||||
owner_info=owner_info,
|
||||
service=item.deployed_service.service.name,
|
||||
service_id=item.deployed_service.service.uuid,
|
||||
pool=item.deployed_service.name,
|
||||
pool_id=item.deployed_service.uuid,
|
||||
ip=props.get('ip', _('unknown')),
|
||||
source_host=item.src_hostname,
|
||||
source_ip=item.src_ip,
|
||||
in_use=item.in_use,
|
||||
)
|
||||
|
||||
def get_item_position(self, parent: 'Model', item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, Provider)
|
||||
return self.calc_item_position(
|
||||
item_uuid,
|
||||
UserService.objects.filter(deployed_service__service__provider=parent).order_by('creation_date'),
|
||||
)
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult[ServicesUsageItem]:
|
||||
parent = ensure.is_instance(parent, Provider)
|
||||
try:
|
||||
userservices = self.odata_filter(
|
||||
UserService.objects.filter(deployed_service__service__provider=parent)
|
||||
.order_by('creation_date')
|
||||
.prefetch_related('deployed_service', 'deployed_service__service', 'user', 'user__manager')
|
||||
)
|
||||
return [ServicesUsage.item_as_dict(k) for k in userservices]
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error getting services usage for %s: %s', parent.uuid, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting services usage')) from None
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> ServicesUsageItem:
|
||||
parent = ensure.is_instance(parent, Provider)
|
||||
return ServicesUsage.item_as_dict(
|
||||
UserService.objects.filter(deployed_service__service_uuid=process_uuid(item)).get()
|
||||
)
|
||||
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, Provider)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Services Usage'))
|
||||
.datetime_column(name='state_date', title=_('Access'))
|
||||
.text_column(name='owner', title=_('Owner'))
|
||||
.text_column(name='service', title=_('Service'))
|
||||
.text_column(name='pool', title=_('Pool'))
|
||||
.text_column(name='unique_id', title='Unique ID')
|
||||
.text_column(name='ip', title=_('IP'))
|
||||
.text_column(name='friendly_name', title=_('Friendly name'))
|
||||
.text_column(name='source_ip', title=_('Src Ip'))
|
||||
.text_column(name='source_host', title=_('Src Host'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.build()
|
||||
)
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, Provider)
|
||||
userservice: UserService
|
||||
try:
|
||||
userservice = UserService.objects.get(
|
||||
uuid=process_uuid(item), deployed_service__service__provider=parent
|
||||
)
|
||||
except UserService.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User service not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting user service %s from %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user service')) from None
|
||||
|
||||
logger.debug('Deleting user service')
|
||||
if userservice.state in (State.USABLE, State.REMOVING):
|
||||
userservice.release()
|
||||
elif userservice.state == State.PREPARING:
|
||||
userservice.cancel()
|
||||
elif userservice.state == State.REMOVABLE:
|
||||
raise exceptions.rest.ResponseError(_('Item already being removed')) from None
|
||||
else:
|
||||
raise exceptions.rest.ResponseError(_('Item is not removable')) from None
|
||||
@@ -1,137 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 logging
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core import types, consts
|
||||
from uds.REST import Handler
|
||||
from uds import models
|
||||
from uds.core.util.stats import counters
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Enclosed methods under /cache path
|
||||
class Stats(Handler):
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
def _usage_stats(self, since: datetime.datetime) -> dict[str, list[dict[str, typing.Any]]]:
|
||||
"""
|
||||
Returns usage stats
|
||||
"""
|
||||
auths: dict[str, list[dict[str, typing.Any]]] = {}
|
||||
for a in models.Authenticator.objects.all():
|
||||
services: typing.Optional[types.stats.AccumStat] = None
|
||||
userservices: typing.Optional[types.stats.AccumStat] = None
|
||||
stats: list[dict[str, typing.Any]] = []
|
||||
|
||||
services_counter_iterator = counters.enumerate_accumulated_counters(
|
||||
interval_type=models.StatsCountersAccum.IntervalType.HOUR,
|
||||
counter_type=types.stats.CounterType.AUTH_SERVICES,
|
||||
owner_id=a.id,
|
||||
since=since,
|
||||
infer_owner_type_from=a, # To infer the owner type
|
||||
)
|
||||
|
||||
user_with_servicescount_iter = iter(
|
||||
counters.enumerate_accumulated_counters(
|
||||
interval_type=models.StatsCountersAccum.IntervalType.HOUR,
|
||||
counter_type=types.stats.CounterType.AUTH_USERS_WITH_SERVICES,
|
||||
owner_id=a.id,
|
||||
since=since,
|
||||
infer_owner_type_from=a, # To infer the owner type
|
||||
)
|
||||
)
|
||||
|
||||
for user_counter in counters.enumerate_accumulated_counters(
|
||||
interval_type=models.StatsCountersAccum.IntervalType.HOUR,
|
||||
counter_type=types.stats.CounterType.AUTH_USERS,
|
||||
owner_id=a.id,
|
||||
since=since,
|
||||
infer_owner_type_from=a, # To infer the owner type
|
||||
):
|
||||
try:
|
||||
while True:
|
||||
services_counter = next(services_counter_iterator)
|
||||
if services_counter.stamp >= user_counter.stamp:
|
||||
break
|
||||
if user_counter.stamp == services_counter.stamp:
|
||||
services = services_counter
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
try:
|
||||
while True:
|
||||
uservices_counter = next(user_with_servicescount_iter)
|
||||
if uservices_counter.stamp >= user_counter.stamp:
|
||||
break
|
||||
if user_counter.stamp == uservices_counter.stamp:
|
||||
userservices = uservices_counter
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
# Update last seen date
|
||||
stats.append(
|
||||
{
|
||||
'stamp': user_counter.stamp,
|
||||
'users': (
|
||||
{'min': user_counter.min, 'max': user_counter.max, 'sum': user_counter.sum}
|
||||
if user_counter
|
||||
else None
|
||||
),
|
||||
'services': (
|
||||
{'min': services.min, 'max': services.max, 'sum': services.sum}
|
||||
if services
|
||||
else None
|
||||
),
|
||||
'user_services': (
|
||||
{'min': userservices.min, 'max': userservices.max, 'sum': userservices.sum}
|
||||
if userservices
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
# print(len(stats), stats[-1], datetime.datetime.fromtimestamp(lastSeen), since)
|
||||
auths[a.uuid] = stats
|
||||
|
||||
return auths
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
"""
|
||||
Processes get method. Basically, clears & purges the cache, no matter what params
|
||||
"""
|
||||
# Default returns usage stats for last day
|
||||
return self._usage_stats(timezone.localtime() - datetime.timedelta(days=1))
|
||||
@@ -1,231 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2021 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 codecs
|
||||
import datetime
|
||||
import logging
|
||||
import pickle # nosec: pickle is used to cache data, not to load it
|
||||
import pickletools
|
||||
import typing
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
from uds import models
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.util import permissions
|
||||
from uds.core.util.cache import Cache
|
||||
from uds.core.util.model import process_uuid, sql_now
|
||||
from uds.core.types.states import State
|
||||
from uds.core.util.stats import counters
|
||||
from uds.REST import Handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
cache = Cache('StatsDispatcher')
|
||||
|
||||
# Enclosed methods under /stats path
|
||||
SINCE: typing.Final[int] = 14 # Days, if higer values used, ensure mysql/mariadb has a bigger sort buffer
|
||||
USE_MAX: typing.Final[int] = True
|
||||
CACHE_TIME: typing.Final[int] = 60 * 60 # 1 hour
|
||||
|
||||
|
||||
def get_servicepools_counters(
|
||||
servicepool: typing.Optional[models.ServicePool],
|
||||
counter_type: types.stats.CounterType,
|
||||
since_days: int = SINCE,
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
val: list[dict[str, typing.Any]] = []
|
||||
try:
|
||||
cache_key = (servicepool and str(servicepool.id) or 'all') + str(counter_type) + str(since_days)
|
||||
# Get now but with 0 minutes and 0 seconds
|
||||
to = sql_now().replace(minute=0, second=0, microsecond=0)
|
||||
since: datetime.datetime = to - datetime.timedelta(days=since_days)
|
||||
|
||||
cached_value: typing.Optional[bytes] = cache.get(cache_key)
|
||||
if not cached_value:
|
||||
if not servicepool:
|
||||
servicepool = models.ServicePool()
|
||||
servicepool.id = -1 # Global stats
|
||||
else:
|
||||
servicepool = servicepool
|
||||
|
||||
stats = counters.enumerate_accumulated_counters(
|
||||
interval_type=models.StatsCountersAccum.IntervalType.HOUR,
|
||||
counter_type=counter_type,
|
||||
owner_type=types.stats.CounterOwnerType.SERVICEPOOL,
|
||||
owner_id=servicepool.id if servicepool.id != -1 else None,
|
||||
since=since,
|
||||
points=since_days * 24, # One point per hour
|
||||
)
|
||||
val = [
|
||||
{
|
||||
'stamp': x.stamp,
|
||||
'value': (x.sum / x.count if x.count > 0 else 0) if not USE_MAX else x.max,
|
||||
}
|
||||
for x in stats
|
||||
]
|
||||
|
||||
# logger.debug('val: %s', val)
|
||||
if len(val) >= 2:
|
||||
cache.put(
|
||||
cache_key,
|
||||
codecs.encode(pickletools.optimize(pickle.dumps(val, protocol=-1)), 'zip'),
|
||||
CACHE_TIME * 2,
|
||||
)
|
||||
else:
|
||||
# Generate as much points as needed with 0 value
|
||||
val = [
|
||||
{'stamp': since + datetime.timedelta(hours=i), 'value': 0} for i in range(since_days * 24)
|
||||
]
|
||||
else:
|
||||
val = pickle.loads(
|
||||
codecs.decode(cached_value, 'zip')
|
||||
) # nosec: pickle is used to cache data, not to load it
|
||||
|
||||
# return [{'stamp': since + datetime.timedelta(hours=i*10), 'value': i*i*counter_type//4} for i in range(300)]
|
||||
|
||||
return val
|
||||
except Exception as e:
|
||||
logger.exception('getServicesPoolsCounters')
|
||||
raise exceptions.rest.ResponseError('can\'t create stats for objects!!!') from e
|
||||
|
||||
|
||||
class System(Handler):
|
||||
"""
|
||||
{
|
||||
'paths': [
|
||||
"/system/overview", "Returns a json object with the number of services, service pools, users, etc",
|
||||
"/system/stats/assigned", "Returns a chart of assigned services (all pools)",
|
||||
"/system/stats/inuse", "Returns a chart of in use services (all pools)",
|
||||
"/system/stats/cached", "Returns a chart of cached services (all pools)",
|
||||
"/system/stats/complete", "Returns a chart of complete services (all pools)",
|
||||
"/system/stats/assigned/<servicePoolId>", "Returns a chart of assigned services (for a pool)",
|
||||
"/system/stats/inuse/<servicePoolId>", "Returns a chart of in use services (for a pool)",
|
||||
"/system/stats/cached/<servicePoolId>", "Returns a chart of cached services (for a pool)",
|
||||
"/system/stats/complete/<servicePoolId>", "Returns a chart of complete services (for a pool)",
|
||||
],
|
||||
'comments': [
|
||||
"Must be admin to access this",
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
logger.debug('args: %s', self._args)
|
||||
# Only allow admin user for global stats
|
||||
if len(self._args) == 1:
|
||||
if self._args[0] == 'overview': # System overview
|
||||
if not self._user.is_admin:
|
||||
raise exceptions.rest.AccessDenied()
|
||||
|
||||
fltr_user = models.User.objects.filter(
|
||||
userServices__state__in=types.states.State.VALID_STATES
|
||||
).order_by()
|
||||
users = models.User.objects.all().count()
|
||||
users_with_services = (
|
||||
fltr_user.values('id').distinct().count()
|
||||
) # Use "values" to simplify query (only id)
|
||||
number_assigned_user_services = fltr_user.values('id').count()
|
||||
|
||||
groups: int = models.Group.objects.count()
|
||||
services: int = models.Service.objects.count()
|
||||
service_pools: int = models.ServicePool.objects.count()
|
||||
meta_pools: int = models.MetaPool.objects.count()
|
||||
user_services: int = models.UserService.objects.exclude(
|
||||
state__in=(State.REMOVED, State.ERROR)
|
||||
).count()
|
||||
restrained_services_pools: int = models.ServicePool.restraineds_queryset().count()
|
||||
os_managers: int = models.OSManager.objects.count()
|
||||
transports_: int = models.Transport.objects.count()
|
||||
networks: int = models.Network.objects.count()
|
||||
calendars: int = models.Calendar.objects.count()
|
||||
tunnels: int = models.Server.objects.filter(type=types.servers.ServerType.TUNNEL).count()
|
||||
auths: int = models.Authenticator.objects.count()
|
||||
|
||||
return {
|
||||
'users': users,
|
||||
'users_with_services': users_with_services,
|
||||
'groups': groups,
|
||||
'services': services,
|
||||
'service_pools': service_pools,
|
||||
'meta_pools': meta_pools,
|
||||
'user_services': user_services,
|
||||
'assigned_user_services': number_assigned_user_services,
|
||||
'restrained_services_pools': restrained_services_pools,
|
||||
'os_managers': os_managers,
|
||||
'transports': transports_,
|
||||
'networks': networks,
|
||||
'calendars': calendars,
|
||||
'tunnels': tunnels,
|
||||
'authenticators': auths,
|
||||
}
|
||||
|
||||
if len(self.args) in (2, 3):
|
||||
# Extract pool if provided
|
||||
pool: typing.Optional[models.ServicePool] = None
|
||||
if len(self.args) == 3:
|
||||
try:
|
||||
pool = models.ServicePool.objects.get(uuid=process_uuid(self._args[2]))
|
||||
except Exception:
|
||||
pool = None
|
||||
# If pool is None, needs admin also
|
||||
if not pool and not self._user.is_admin:
|
||||
raise exceptions.rest.AccessDenied()
|
||||
# Check permission for pool..
|
||||
if not permissions.has_access(
|
||||
self._user, typing.cast('Model', pool), types.permissions.PermissionType.READ
|
||||
):
|
||||
raise exceptions.rest.AccessDenied()
|
||||
if self.args[0] == 'stats':
|
||||
if self.args[1] == 'assigned':
|
||||
return get_servicepools_counters(pool, counters.types.stats.CounterType.ASSIGNED)
|
||||
elif self.args[1] == 'inuse':
|
||||
return get_servicepools_counters(pool, counters.types.stats.CounterType.INUSE)
|
||||
elif self.args[1] == 'cached':
|
||||
return get_servicepools_counters(pool, counters.types.stats.CounterType.CACHED)
|
||||
elif self.args[1] == 'complete':
|
||||
assigned = get_servicepools_counters(pool, counters.types.stats.CounterType.ASSIGNED)
|
||||
inuse = get_servicepools_counters(pool, counters.types.stats.CounterType.INUSE)
|
||||
cached = get_servicepools_counters(pool, counters.types.stats.CounterType.CACHED)
|
||||
return {
|
||||
'assigned': assigned,
|
||||
'inuse': inuse,
|
||||
'cached': cached,
|
||||
}
|
||||
|
||||
raise exceptions.rest.RequestError('invalid request')
|
||||
|
||||
def put(self) -> typing.Any:
|
||||
raise exceptions.rest.RequestError() # Not allowed right now
|
||||
@@ -1,266 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2021 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 logging
|
||||
import typing
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.REST import Handler
|
||||
from uds import models
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.util import ensure
|
||||
from uds.core import consts, exceptions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid parameters accepted by ticket creation method
|
||||
VALID_PARAMS = (
|
||||
'authId',
|
||||
'auth_id',
|
||||
'authTag',
|
||||
'auth_tag',
|
||||
'authSmallName',
|
||||
'auth',
|
||||
'auth_name',
|
||||
'username',
|
||||
'realname',
|
||||
'password',
|
||||
'groups',
|
||||
'servicePool',
|
||||
'service_pool',
|
||||
'transport', # Admited to be backwards compatible, but not used. Will be removed on a future release.
|
||||
'force',
|
||||
'userIp',
|
||||
'user_ip',
|
||||
)
|
||||
|
||||
|
||||
# Enclosed methods under /tickets path
|
||||
class Tickets(Handler):
|
||||
"""
|
||||
Processes tickets access requests.
|
||||
Tickets are element used to "register" & "allow access" to users to a service.
|
||||
Designed to be used by external systems (like web services) to allow access to users to services.
|
||||
|
||||
The rest API accepts the following parameters:
|
||||
authId: uuid of the authenticator for the user | Mutually excluyents
|
||||
authSmallName: tag of the authenticator (alias for "authTag") | But must include one of theese
|
||||
authTag: tag of the authenticator |
|
||||
auth: Name of authenticator |
|
||||
userIp: Direccion IP del cliente. Si no se pasa, no se puede filtar
|
||||
username:
|
||||
password:
|
||||
groups:
|
||||
servicePool:
|
||||
transport: Ignored. Transport must be auto-detected on ticket auth
|
||||
force: If "1" or "true" will ensure that:
|
||||
- Groups exists on authenticator
|
||||
- servicePool has these groups in it's allowed list
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
@staticmethod
|
||||
def result(result: str = '', error: typing.Optional[str] = None) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Returns a result for a Ticket request
|
||||
"""
|
||||
res = {'result': result, 'date': timezone.localtime()}
|
||||
if error is not None:
|
||||
res['error'] = error
|
||||
return res
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
"""
|
||||
Processes get requests, currently none
|
||||
"""
|
||||
logger.debug('Ticket args for GET: %s', self._args)
|
||||
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
def _check_parameters(self) -> None:
|
||||
# Parameters can only be theese
|
||||
for p in self._params:
|
||||
if p not in VALID_PARAMS:
|
||||
logger.debug('Parameter %s not in valid ticket parameters list', p)
|
||||
raise exceptions.rest.RequestError('Invalid parameters')
|
||||
|
||||
if len(self._args) != 1 or self._args[0] not in ('create',):
|
||||
raise exceptions.rest.RequestError('Invalid method')
|
||||
|
||||
try:
|
||||
for i in (
|
||||
'authId',
|
||||
'auth_id',
|
||||
'authTag',
|
||||
'auth_tag',
|
||||
'auth_label',
|
||||
'auth',
|
||||
'auth_name',
|
||||
'authSmallName',
|
||||
):
|
||||
if i in self._params:
|
||||
raise StopIteration
|
||||
|
||||
if 'username' in self._params and 'groups' in self._params:
|
||||
raise StopIteration()
|
||||
|
||||
raise exceptions.rest.RequestError('Invalid parameters (no auth or username/groups)')
|
||||
except StopIteration:
|
||||
pass # All ok
|
||||
|
||||
# Must be invoked as '/rest/ticket/create, with "username", ("authId" or "auth_id") or ("auth_tag" or "authSmallName" or "authTag"), "groups" (array) and optionally "time" (in seconds) as paramteres
|
||||
def put(
|
||||
self,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Processes put requests, currently only under "create"
|
||||
"""
|
||||
logger.debug(self._args)
|
||||
|
||||
# Check that call is correct (pamateters, args, ...)
|
||||
self._check_parameters()
|
||||
|
||||
force: bool = self.get_param('force') in ('1', 'true', 'True', True)
|
||||
|
||||
try:
|
||||
service_pool_id: typing.Optional[str] = None
|
||||
|
||||
# First param is recommended, last ones are compatible with old versions
|
||||
auth_id = self.get_param('auth_id', 'authId')
|
||||
auth_name = self.get_param('auth_name', 'auth')
|
||||
auth_label = self.get_param('auth_label', 'auth_tag', 'authTag', 'authSmallName')
|
||||
|
||||
# Will raise an exception if no auth found
|
||||
if auth_id:
|
||||
auth = models.Authenticator.objects.get(uuid=process_uuid(auth_id.lower()))
|
||||
elif auth_name:
|
||||
auth = models.Authenticator.objects.get(name=auth_name)
|
||||
else:
|
||||
auth = models.Authenticator.objects.get(small_name=auth_label)
|
||||
|
||||
username: str = self.get_param('username')
|
||||
password: str = self.get_param('password')
|
||||
# Some machines needs password, depending on configuration
|
||||
|
||||
groups_ids: list[str] = []
|
||||
for group_name in ensure.as_list(self.get_param('groups')):
|
||||
try:
|
||||
groups_ids.append(auth.groups.get(name=group_name).uuid or '')
|
||||
except Exception:
|
||||
logger.info(
|
||||
'Group %s from ticket does not exists on auth %s, forced creation: %s',
|
||||
group_name,
|
||||
auth,
|
||||
force,
|
||||
)
|
||||
if force: # Force creation by call
|
||||
groups_ids.append(
|
||||
auth.groups.create(
|
||||
name=group_name,
|
||||
comments='Autocreated form ticket by using force paratemeter',
|
||||
).uuid
|
||||
or ''
|
||||
)
|
||||
|
||||
if not groups_ids: # No valid group in groups names
|
||||
raise exceptions.rest.RequestError(
|
||||
'Authenticator does not contain ANY of the requested groups and force is not used'
|
||||
)
|
||||
|
||||
try:
|
||||
time = int(self.get_param('time') or 60)
|
||||
time = 60 if time < 1 else time
|
||||
except Exception:
|
||||
time = 60
|
||||
realname: str = self.get_param('realname', 'username') or ''
|
||||
|
||||
pool_uuid = self.get_param('servicepool', 'servicePool')
|
||||
if pool_uuid:
|
||||
# Check if is pool or metapool
|
||||
pool_uuid = process_uuid(pool_uuid)
|
||||
pool: typing.Union[models.ServicePool, models.MetaPool]
|
||||
|
||||
try:
|
||||
pool = models.MetaPool.objects.get(
|
||||
uuid=pool_uuid
|
||||
) # If not an metapool uuid, will process it as a servicePool
|
||||
if force:
|
||||
# First, add groups to metapool
|
||||
for group_to_add in set(groups_ids) - set(pool.assignedGroups.values_list('uuid', flat=True)):
|
||||
pool.assignedGroups.add(auth.groups.get(uuid=group_to_add))
|
||||
# And now, to ALL metapool members, even those disabled
|
||||
for meta_member in pool.members.all():
|
||||
# Now add groups to pools
|
||||
for group_to_add in set(groups_ids) - set(
|
||||
meta_member.pool.assignedGroups.values_list('uuid', flat=True)
|
||||
):
|
||||
meta_member.pool.assignedGroups.add(auth.groups.get(uuid=group_to_add))
|
||||
|
||||
# For metapool, transport is ignored..
|
||||
|
||||
service_pool_id = 'M' + pool.uuid
|
||||
except models.MetaPool.DoesNotExist:
|
||||
pool = models.ServicePool.objects.get(uuid=pool_uuid)
|
||||
|
||||
# If forced that servicePool must honor groups
|
||||
if force:
|
||||
for group_to_add in set(groups_ids) - set(pool.assignedGroups.values_list('uuid', flat=True)):
|
||||
pool.assignedGroups.add(auth.groups.get(uuid=group_to_add))
|
||||
|
||||
service_pool_id = 'F' + pool.uuid
|
||||
|
||||
except models.Authenticator.DoesNotExist:
|
||||
return Tickets.result(error='Authenticator does not exists')
|
||||
except models.ServicePool.DoesNotExist:
|
||||
pass
|
||||
return Tickets.result(error='Service pool (or metapool) does not exists')
|
||||
except models.Transport.DoesNotExist:
|
||||
pass
|
||||
return Tickets.result(error='Transport does not exists')
|
||||
except Exception as e:
|
||||
return Tickets.result(error=str(e))
|
||||
|
||||
data = {
|
||||
'username': username,
|
||||
'password': CryptoManager.manager().encrypt(password),
|
||||
'realname': realname,
|
||||
'groups': groups_ids,
|
||||
'auth': auth.uuid,
|
||||
'servicePool': service_pool_id,
|
||||
}
|
||||
|
||||
ticket = models.TicketStore.create(data)
|
||||
|
||||
return Tickets.result(ticket)
|
||||
@@ -1,219 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2021 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 logging
|
||||
import re
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import consts, exceptions, transports, types, ui
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.models import Network, ServicePool, Transport
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TransportItem(types.rest.ManagedObjectItem[Transport]):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
comments: str
|
||||
priority: int
|
||||
label: str
|
||||
net_filtering: str
|
||||
networks: list[str]
|
||||
allowed_oss: list[str]
|
||||
pools: list[str]
|
||||
pools_count: int
|
||||
deployed_count: int
|
||||
protocol: str
|
||||
permission: int
|
||||
|
||||
|
||||
class Transports(ModelHandler[TransportItem]):
|
||||
|
||||
MODEL = Transport
|
||||
FIELDS_TO_SAVE = [
|
||||
'name',
|
||||
'comments',
|
||||
'tags',
|
||||
'priority',
|
||||
'net_filtering',
|
||||
'allowed_oss',
|
||||
'label',
|
||||
]
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Transports'))
|
||||
.numeric_column(name='priority', title=_('Priority'), width='6em')
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='pools_count', title=_('Service Pools'), width='6em')
|
||||
.text_column(name='allowed_oss', title=_('Devices'), width='8em')
|
||||
.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,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[transports.Transport]]:
|
||||
return transports.factory().providers().values()
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
transport_type = transports.factory().lookup(for_type)
|
||||
|
||||
if not transport_type:
|
||||
raise exceptions.rest.NotFound(_('Transport type not found: {}').format(for_type))
|
||||
|
||||
with Environment.temporary_environment() as env:
|
||||
transport = transport_type(env, None)
|
||||
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_stock_field(types.rest.stock.StockField.PRIORITY)
|
||||
.add_stock_field(types.rest.stock.StockField.NETWORKS)
|
||||
.add_fields(transport.gui_description())
|
||||
.add_multichoice(
|
||||
name='pools',
|
||||
label=gettext('Service Pools'),
|
||||
choices=[
|
||||
ui.gui.choice_item(x.uuid, x.name)
|
||||
for x in ServicePool.objects.filter(service__isnull=False)
|
||||
.order_by('name')
|
||||
.prefetch_related('service')
|
||||
if transport_type.PROTOCOL in x.service.get_type().allowed_protocols
|
||||
],
|
||||
tooltip=gettext(
|
||||
'Currently assigned services pools. If empty, no service pool is assigned to this transport'
|
||||
),
|
||||
)
|
||||
.new_tab(types.ui.Tab.ADVANCED)
|
||||
.add_multichoice(
|
||||
name='allowed_oss',
|
||||
label=gettext('Allowed Devices'),
|
||||
choices=[
|
||||
ui.gui.choice_item(x.db_value(), x.os_name().title()) for x in consts.os.KNOWN_OS_LIST
|
||||
],
|
||||
tooltip=gettext(
|
||||
'If empty, any kind of device compatible with this transport will be allowed. Else, only devices compatible with selected values will be allowed'
|
||||
),
|
||||
)
|
||||
.add_text(
|
||||
name='label',
|
||||
label=gettext('Label'),
|
||||
tooltip=gettext('Metapool transport label (only used on metapool transports grouping)'),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_item(self, item: 'Model') -> TransportItem:
|
||||
item = ensure.is_instance(item, Transport)
|
||||
type_ = item.get_type()
|
||||
pools = list(item.deployedServices.all().values_list('uuid', flat=True))
|
||||
return TransportItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
priority=item.priority,
|
||||
label=item.label,
|
||||
net_filtering=item.net_filtering,
|
||||
networks=list(item.networks.all().values_list('uuid', flat=True)),
|
||||
allowed_oss=[x for x in item.allowed_oss.split(',')] if item.allowed_oss != '' else [],
|
||||
pools=pools,
|
||||
pools_count=len(pools),
|
||||
deployed_count=item.deployedServices.count(),
|
||||
protocol=type_.PROTOCOL,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
item=item,
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
fields['allowed_oss'] = ','.join(fields['allowed_oss'])
|
||||
# If label has spaces, replace them with underscores
|
||||
fields['label'] = fields['label'].strip().replace(' ', '-')
|
||||
# And ensure small_name chars are valid [ a-zA-Z0-9:-]+
|
||||
if fields['label'] and not re.match(r'^[a-zA-Z0-9:-]+$', fields['label']):
|
||||
raise exceptions.rest.ValidationError(
|
||||
gettext('Label must contain only letters, numbers, ":" and "-"')
|
||||
)
|
||||
|
||||
def post_save(self, item: 'Model') -> None:
|
||||
item = ensure.is_instance(item, Transport)
|
||||
try:
|
||||
networks = self._params['networks']
|
||||
except Exception: # No networks passed in, this is ok
|
||||
logger.debug('No networks')
|
||||
return
|
||||
if networks is None: # None is not provided, empty list is ok and means no networks
|
||||
return
|
||||
from uds.models import ServicePool # Add the import statement for the ServicePool class
|
||||
|
||||
logger.debug('Networks: %s', networks)
|
||||
item.networks.set(Network.objects.filter(uuid__in=networks))
|
||||
|
||||
try:
|
||||
pools = self._params['pools']
|
||||
except Exception:
|
||||
logger.debug('No pools')
|
||||
pools = None
|
||||
|
||||
if pools is None:
|
||||
return
|
||||
|
||||
logger.debug('Pools: %s', pools)
|
||||
item.deployedServices.set(ServicePool.objects.filter(uuid__in=pools))
|
||||
|
||||
# try:
|
||||
# oss = ','.join(self._params['allowed_oss'])
|
||||
# except:
|
||||
# oss = ''
|
||||
# logger.debug('Devices: {0}'.format(oss))
|
||||
# item.allowed_oss = oss
|
||||
# item.save() # Store correctly the allowed_oss
|
||||
@@ -1,166 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-2021 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 collections.abc
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from uds import models
|
||||
from uds.core import consts, exceptions, types
|
||||
from uds.core.auths.auth import is_trusted_source
|
||||
from uds.core.util import log, net
|
||||
from uds.core.util.model import sql_stamp_seconds
|
||||
from uds.core.util.stats import events
|
||||
from uds.REST import Handler
|
||||
|
||||
from .servers import ServerRegisterBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_SESSION_LENGTH = 60 * 60 * 24 * 7 * 2 # Two weeks is max session length for a tunneled connection
|
||||
|
||||
|
||||
# Enclosed methods under /tunnel path
|
||||
class TunnelTicket(Handler):
|
||||
"""
|
||||
Processes tunnel requests
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
PATH = 'tunnel'
|
||||
NAME = 'ticket'
|
||||
|
||||
def get(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
"""
|
||||
Processes get requests
|
||||
"""
|
||||
logger.debug(
|
||||
'Tunnel parameters for GET: %s (%s) from %s',
|
||||
self._args,
|
||||
self._params,
|
||||
self._request.ip,
|
||||
)
|
||||
|
||||
if not is_trusted_source(self._request.ip) or len(self._args) != 3 or len(self._args[0]) != 48:
|
||||
# Invalid requests
|
||||
raise exceptions.rest.AccessDenied()
|
||||
|
||||
# Take token from url
|
||||
token = self._args[2][:48]
|
||||
if not models.Server.validate_token(token, server_type=types.servers.ServerType.TUNNEL):
|
||||
if self._args[1][:4] == 'stop':
|
||||
# "Discard" invalid stop requests, because Applications does not like them.
|
||||
# RDS connections keep alive for a while after the application is finished,
|
||||
# Also, same tunnel can be used for multiple applications, so we need to
|
||||
# discard invalid stop requests. (because the data provided is also for "several" applications)")
|
||||
return {}
|
||||
logger.error('Invalid token %s from %s', token, self._request.ip)
|
||||
raise exceptions.rest.AccessDenied()
|
||||
|
||||
# Try to get ticket from DB
|
||||
try:
|
||||
user, user_service, host, port, extra, key = models.TicketStore.get_for_tunnel(self._args[0])
|
||||
host = host or ''
|
||||
data: dict[str, typing.Any] = {}
|
||||
if self._args[1][:4] == 'stop':
|
||||
sent, recv = self._params['sent'], self._params['recv']
|
||||
# Ensures extra exists...
|
||||
extra = extra or {}
|
||||
now = sql_stamp_seconds()
|
||||
total_time = now - extra.get('b', now - 1)
|
||||
msg = f'User {user.name} stopped tunnel {extra.get("t", "")[:8]}... to {host}:{port}: u:{sent}/d:{recv}/t:{total_time}.'
|
||||
log.log(user.manager, types.log.LogLevel.INFO, msg)
|
||||
log.log(user_service, types.log.LogLevel.INFO, msg)
|
||||
|
||||
# Try to log Close event
|
||||
try:
|
||||
# If pool does not exists, do not log anything
|
||||
events.add_event(
|
||||
user_service.deployed_service,
|
||||
events.types.stats.EventType.TUNNEL_CLOSE,
|
||||
duration=total_time,
|
||||
sent=sent,
|
||||
received=recv,
|
||||
tunnel=extra.get('t', 'unknown'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('Error logging tunnel close event: %s', e)
|
||||
|
||||
else:
|
||||
if net.ip_to_long(self._args[1][:32]).version == 0:
|
||||
raise Exception('Invalid from IP')
|
||||
events.add_event(
|
||||
user_service.deployed_service,
|
||||
events.types.stats.EventType.TUNNEL_OPEN,
|
||||
username=user.pretty_name,
|
||||
srcip=self._args[1],
|
||||
dstip=host,
|
||||
tunnel=self._args[0],
|
||||
)
|
||||
msg = f'User {user.name} started tunnel {self._args[0][:8]}... to {host}:{port} from {self._args[1]}.'
|
||||
log.log(user.manager, types.log.LogLevel.INFO, msg)
|
||||
log.log(user_service, types.log.LogLevel.INFO, msg)
|
||||
# Generate new, notify only, ticket
|
||||
notify_ticket = models.TicketStore.create_for_tunnel(
|
||||
userservice=user_service,
|
||||
port=port,
|
||||
host=host,
|
||||
extra={
|
||||
't': self._args[0], # ticket
|
||||
'b': sql_stamp_seconds(), # Begin time stamp
|
||||
},
|
||||
validity=MAX_SESSION_LENGTH,
|
||||
)
|
||||
data = {'host': host, 'port': port, 'notify': notify_ticket, 'tunnel_key': key}
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.info('Ticket ignored: %s', e)
|
||||
raise exceptions.rest.AccessDenied() from e
|
||||
|
||||
|
||||
class TunnelRegister(ServerRegisterBase):
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
PATH = 'tunnel'
|
||||
NAME = 'register'
|
||||
|
||||
# Just a compatibility method for old tunnel servers
|
||||
def post(self) -> dict[str, typing.Any]:
|
||||
self._params['type'] = types.servers.ServerType.TUNNEL
|
||||
self._params['os'] = self._params.get(
|
||||
'os', types.os.KnownOS.LINUX.os_name()
|
||||
) # Legacy tunnels are always linux
|
||||
self._params['version'] = '' # No version for legacy tunnels, does not respond to API requests from UDS
|
||||
self._params['certificate'] = (
|
||||
'' # No certificate for legacy tunnels, does not respond to API requests from UDS
|
||||
)
|
||||
return super().post()
|
||||
@@ -1,272 +0,0 @@
|
||||
# -*- 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 logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
|
||||
import uds.core.types.permissions
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.util import permissions, validators, ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds import models
|
||||
from uds.REST.model import DetailHandler, ModelHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TunnelServerItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
hostname: str
|
||||
ip: str
|
||||
mac: str
|
||||
maintenance: bool
|
||||
|
||||
|
||||
class TunnelServers(DetailHandler[TunnelServerItem]):
|
||||
CUSTOM_METHODS = ['maintenance']
|
||||
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
name='TunnelServers', description='Tunnel servers assigned to a tunnel'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def as_tunnel_server_item(item: models.Server) -> TunnelServerItem:
|
||||
return TunnelServerItem(
|
||||
id=item.uuid,
|
||||
hostname=item.hostname,
|
||||
ip=item.ip,
|
||||
mac=item.mac if item.mac != consts.NULL_MAC else '',
|
||||
maintenance=item.maintenance_mode,
|
||||
)
|
||||
|
||||
def get_item_position(self, parent: Model, item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
return self.calc_item_position(item_uuid, parent.servers.all())
|
||||
|
||||
def get_items(
|
||||
self, parent: 'Model'
|
||||
) -> types.rest.ItemsResult[TunnelServerItem]:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
return [
|
||||
TunnelServers.as_tunnel_server_item(i)
|
||||
for i in self.odata_filter(parent.servers.all())
|
||||
]
|
||||
|
||||
def get_item(
|
||||
self, parent: 'Model', item: str
|
||||
) -> TunnelServerItem:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
return TunnelServers.as_tunnel_server_item(parent.servers.get(uuid=process_uuid(item)))
|
||||
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
return (
|
||||
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'))
|
||||
.dict_column(
|
||||
name='maintenance',
|
||||
title=_('State'),
|
||||
dct={True: _('Maintenance'), False: _('Normal')},
|
||||
)
|
||||
.row_style(prefix='row-maintenance-', field='maintenance')
|
||||
).build()
|
||||
|
||||
# Cannot save a tunnel server, it's not editable...
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
try:
|
||||
parent.servers.remove(models.Server.objects.get(uuid=process_uuid(item)))
|
||||
except models.Server.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Tunnel server not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error deleting tunnel server %s from %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error deleting tunnel server')) from None
|
||||
|
||||
# Custom methods
|
||||
def maintenance(self, parent: 'Model', id: str) -> typing.Any:
|
||||
"""
|
||||
API:
|
||||
Custom method that swaps maintenance mode state for a tunnel server
|
||||
|
||||
"""
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
item = models.Server.objects.get(uuid=process_uuid(id))
|
||||
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
item.maintenance_mode = not item.maintenance_mode
|
||||
item.save()
|
||||
return 'ok'
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TunnelItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
comments: str
|
||||
host: str
|
||||
port: int
|
||||
tags: list[str]
|
||||
transports_count: int
|
||||
servers_count: int
|
||||
permission: uds.core.types.permissions.PermissionType
|
||||
|
||||
|
||||
# Enclosed methods under /auth path
|
||||
class Tunnels(ModelHandler[TunnelItem]):
|
||||
|
||||
PATH = 'tunnels'
|
||||
NAME = 'tunnels'
|
||||
MODEL = models.ServerGroup
|
||||
FILTER = {'type': types.servers.ServerType.TUNNEL}
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('tunnels', needs_parent=True),
|
||||
types.rest.ModelCustomMethod('assign', needs_parent=True),
|
||||
]
|
||||
|
||||
DETAIL = {'servers': TunnelServers}
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'host:', 'port:0']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Tunnels'))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.text_column(name='host', title=_('Host'))
|
||||
.numeric_column(name='port', title=_('Port'), width='6em')
|
||||
.numeric_column(name='servers_count', title=_('Servers'), width='1rem')
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
name='Tunnels',
|
||||
description='Tunnel management',
|
||||
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_text(
|
||||
name='host',
|
||||
label=gettext('Hostname'),
|
||||
tooltip=gettext(
|
||||
'Hostname or IP address of the server where the tunnel is visible by the users'
|
||||
),
|
||||
)
|
||||
.add_numeric(
|
||||
name='port',
|
||||
default=443,
|
||||
label=gettext('Port'),
|
||||
tooltip=gettext('Port where the tunnel is visible by the users'),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_item(self, item: 'Model') -> TunnelItem:
|
||||
item = ensure.is_instance(item, models.ServerGroup)
|
||||
return TunnelItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
comments=item.comments,
|
||||
host=item.host,
|
||||
port=item.port,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
transports_count=item.transports.count(),
|
||||
servers_count=item.servers.count(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
fields['type'] = types.servers.ServerType.TUNNEL.value
|
||||
fields['port'] = int(fields['port'])
|
||||
# Ensure host is a valid IP(4 or 6) or hostname
|
||||
validators.validate_host(fields['host'])
|
||||
|
||||
def validate_delete(self, item: 'Model') -> None:
|
||||
item = ensure.is_instance(item, models.ServerGroup)
|
||||
# Only can delete if no ServicePools attached
|
||||
if item.transports.count() > 0:
|
||||
raise exceptions.rest.RequestError(
|
||||
gettext('Cannot delete a tunnel server group with transports attached')
|
||||
)
|
||||
|
||||
def assign(self, parent: 'Model') -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
self.check_access(parent, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
|
||||
server: typing.Optional['models.Server'] = None # Avoid warning on reference before assignment
|
||||
|
||||
item = self._args[-1]
|
||||
|
||||
if not item:
|
||||
raise exceptions.rest.RequestError('No server specified')
|
||||
|
||||
try:
|
||||
server = models.Server.objects.get(uuid=process_uuid(item))
|
||||
self.check_access(server, uds.core.types.permissions.PermissionType.READ)
|
||||
parent.servers.add(server)
|
||||
except models.Server.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Tunnel server not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error assigning server %s to %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error assigning server')) from None
|
||||
|
||||
return 'ok'
|
||||
|
||||
def tunnels(self, parent: 'Model') -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
"""
|
||||
Custom method that returns all tunnels of a tunnel server NOT already assigned to THIS tunnel group
|
||||
:param item:
|
||||
"""
|
||||
all_servers = set(parent.servers.all())
|
||||
return [
|
||||
{
|
||||
'id': i.uuid,
|
||||
'name': i.hostname,
|
||||
}
|
||||
for i in models.Server.objects.filter(type=types.servers.ServerType.TUNNEL)
|
||||
if permissions.effective_permissions(self._user, i)
|
||||
>= uds.core.types.permissions.PermissionType.READ
|
||||
and i not in all_servers
|
||||
]
|
||||
@@ -1,681 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-2024 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 collections.abc
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db.models import Model, QuerySet, OuterRef, Subquery
|
||||
|
||||
import uds.core.types.permissions
|
||||
from uds import models
|
||||
from uds.core import exceptions, types
|
||||
from uds.core.managers.userservice import UserServiceManager
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.types.states import State
|
||||
from uds.core.util import ensure, log, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class UserServiceItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
pool_id: str
|
||||
unique_id: str
|
||||
friendly_name: str
|
||||
state: str
|
||||
os_state: str
|
||||
state_date: datetime.datetime
|
||||
creation_date: datetime.datetime
|
||||
revision: str
|
||||
ip: str
|
||||
actor_version: str
|
||||
|
||||
# For cache
|
||||
cache_level: int | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
# Optional, used on some cases (e.g. assigned services)
|
||||
pool_name: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
# For assigned
|
||||
owner: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
owner_info: dict[str, str] | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
in_use: bool | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
in_use_date: datetime.datetime | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
source_host: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
source_ip: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
class AssignedUserService(DetailHandler[UserServiceItem]):
|
||||
"""
|
||||
Rest handler for Assigned Services, wich parent is Service
|
||||
"""
|
||||
|
||||
CUSTOM_METHODS = ['reset']
|
||||
|
||||
@staticmethod
|
||||
def userservice_item(
|
||||
item: models.UserService,
|
||||
props: typing.Optional[dict[str, typing.Any]] = None,
|
||||
is_cache: bool = False,
|
||||
) -> 'UserServiceItem':
|
||||
"""
|
||||
Converts an assigned/cached service db item to a dictionary for REST response
|
||||
Args:
|
||||
item: item to convert
|
||||
props: properties to include
|
||||
is_cache: If item is from cache or not
|
||||
"""
|
||||
if props is None:
|
||||
props = dict(item.properties)
|
||||
|
||||
val = UserServiceItem(
|
||||
id=item.uuid,
|
||||
pool_id=item.deployed_service.uuid,
|
||||
unique_id=item.unique_id,
|
||||
friendly_name=item.friendly_name,
|
||||
state=(
|
||||
item.state
|
||||
if not (props.get('destroy_after') and item.state == State.PREPARING)
|
||||
else State.CANCELING
|
||||
), # Destroy after means that we need to cancel AFTER finishing preparing, but not before...
|
||||
os_state=item.os_state,
|
||||
state_date=item.state_date,
|
||||
creation_date=item.creation_date,
|
||||
revision=item.publication and str(item.publication.revision) or '',
|
||||
ip=props.get('ip', _('unknown')),
|
||||
actor_version=props.get('actor_version', _('unknown')),
|
||||
)
|
||||
|
||||
if is_cache:
|
||||
val.cache_level = item.cache_level
|
||||
else:
|
||||
if item.user is None:
|
||||
owner = ''
|
||||
owner_info = {'auth_id': '', 'user_id': ''}
|
||||
else:
|
||||
owner = item.user.pretty_name
|
||||
owner_info = {
|
||||
'auth_id': item.user.manager.uuid,
|
||||
'user_id': item.user.uuid,
|
||||
}
|
||||
|
||||
val.owner = owner
|
||||
val.owner_info = owner_info
|
||||
val.in_use = item.in_use
|
||||
val.in_use_date = item.in_use_date
|
||||
val.source_host = item.src_hostname
|
||||
val.source_ip = item.src_ip
|
||||
|
||||
return val
|
||||
|
||||
def apply_sort(self, qs: QuerySet[typing.Any]) -> list[typing.Any] | QuerySet[typing.Any]:
|
||||
def annotated_sort(field: str, descending: bool) -> QuerySet[typing.Any]:
|
||||
prop_value_subquery = models.Properties.objects.filter(
|
||||
owner_id=OuterRef('uuid'), owner_type='userservice', key=field
|
||||
).values('value')[:1]
|
||||
return qs.annotate(prop_value=Subquery(prop_value_subquery)).order_by(
|
||||
f'{"-" if descending else ""}prop_value'
|
||||
)
|
||||
|
||||
if sort_info := self.get_sort_field_info('ip', 'actor_version'):
|
||||
return annotated_sort(*sort_info)
|
||||
first_order_by_field, is_descending = sort_info
|
||||
|
||||
return super().apply_sort(qs)
|
||||
|
||||
def get_qs(self, for_cached: bool) -> QuerySet[models.UserService]:
|
||||
parent = ensure.is_instance(self._parent, models.ServicePool)
|
||||
if for_cached:
|
||||
return parent.cached_users_services()
|
||||
return parent.assigned_user_services()
|
||||
|
||||
def do_get_item_position(self, for_cached: bool, parent: 'Model', item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return self.calc_item_position(item_uuid, self.get_qs(for_cached).all())
|
||||
|
||||
def get_item_position(self, parent: Model, item_uuid: str) -> int:
|
||||
return self.do_get_item_position(for_cached=False, parent=parent, item_uuid=item_uuid)
|
||||
|
||||
def do_get_item(
|
||||
self,
|
||||
parent: 'Model',
|
||||
item: str,
|
||||
for_cached: bool,
|
||||
) -> 'UserServiceItem':
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
|
||||
return AssignedUserService.userservice_item(
|
||||
self.get_qs(for_cached).get(uuid=process_uuid(item)),
|
||||
props={
|
||||
k: v
|
||||
for k, v in models.Properties.objects.filter(
|
||||
owner_type='userservice', owner_id=process_uuid(item)
|
||||
).values_list('key', 'value')
|
||||
},
|
||||
is_cache=for_cached,
|
||||
)
|
||||
|
||||
def do_get_items(
|
||||
self,
|
||||
parent: 'Model',
|
||||
for_cached: bool,
|
||||
) -> types.rest.ItemsResult['UserServiceItem']:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
|
||||
def get_qs() -> QuerySet[models.UserService]:
|
||||
if for_cached:
|
||||
return parent.cached_users_services()
|
||||
return parent.assigned_user_services()
|
||||
|
||||
# First, fetch all properties for all assigned services on this pool
|
||||
# We can cache them, because they are going to be readed anyway...
|
||||
properties: dict[str, typing.Any] = collections.defaultdict(dict)
|
||||
for id, key, value in models.Properties.objects.filter(
|
||||
owner_type='userservice',
|
||||
owner_id__in=get_qs().values_list('uuid', flat=True),
|
||||
).values_list('owner_id', 'key', 'value'):
|
||||
properties[id][key] = value
|
||||
|
||||
return [
|
||||
AssignedUserService.userservice_item(k, properties.get(k.uuid, {}))
|
||||
for k in self.odata_filter(
|
||||
get_qs().all().prefetch_related('deployed_service', 'publication', 'user')
|
||||
)
|
||||
]
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult['UserServiceItem']:
|
||||
return self.do_get_items(parent, for_cached=False)
|
||||
|
||||
def get_item(
|
||||
self,
|
||||
parent: 'Model',
|
||||
item: str,
|
||||
) -> 'UserServiceItem':
|
||||
return self.do_get_item(parent, item, for_cached=False)
|
||||
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
table_info = ui_utils.TableBuilder(_('Assigned Services')).datetime_column(
|
||||
name='creation_date', title=_('Creation date')
|
||||
)
|
||||
if parent.service.get_type().publication_type is not None:
|
||||
table_info.text_column(name='revision', title=_('Revision'))
|
||||
|
||||
return (
|
||||
table_info.text_column(name='unique_id', title='Unique ID')
|
||||
.text_column(name='ip', title=_('IP'))
|
||||
.text_column(name='friendly_name', title=_('Friendly name'))
|
||||
.dict_column(name='state', title=_('status'), dct=State.literals_dict())
|
||||
.datetime_column(name='state_date', title=_('Status date'))
|
||||
.text_column(name='in_use', title=_('In Use'))
|
||||
.text_column(name='source_host', title=_('Src Host'))
|
||||
.text_column(name='source_ip', title=_('Src Ip'))
|
||||
.text_column(name='owner', title=_('Owner'))
|
||||
.text_column(name='actor_version', title=_('Actor version'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.with_field_mappings(revision='deployed_service.publications.revision')
|
||||
.with_filter_fields('creation_date', 'unique_id', 'friendly_name', 'state', 'in_use')
|
||||
).build()
|
||||
|
||||
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
try:
|
||||
user_service: models.UserService = parent.assigned_user_services().get(uuid=process_uuid(item))
|
||||
logger.debug('Getting logs for %s', user_service)
|
||||
return log.get_logs(user_service)
|
||||
except models.UserService.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User service not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting user service logs for %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user service logs')) from e
|
||||
|
||||
# This is also used by CachedService, so we use "userServices" directly and is valid for both
|
||||
def delete_item(self, parent: 'Model', item: str, cache: bool = False) -> None:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
try:
|
||||
if cache:
|
||||
userservice = parent.cached_users_services().get(uuid=process_uuid(item))
|
||||
else:
|
||||
userservice = parent.assigned_user_services().get(uuid=process_uuid(item))
|
||||
except Exception as e:
|
||||
logger.error('Error deleting user service %s from %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error deleting user service')) from None
|
||||
|
||||
if userservice.user: # All assigned services have a user
|
||||
log_string = f'Deleted assigned user service {userservice.friendly_name} to user {userservice.user.pretty_name} by {self._user.pretty_name}'
|
||||
else:
|
||||
log_string = f'Deleted cached user service {userservice.friendly_name} by {self._user.pretty_name}'
|
||||
|
||||
if userservice.state in (State.USABLE, State.REMOVING):
|
||||
userservice.release()
|
||||
elif userservice.state == State.PREPARING:
|
||||
userservice.cancel()
|
||||
elif userservice.state == State.REMOVABLE:
|
||||
raise exceptions.rest.RequestError(_('Item already being removed')) from None
|
||||
else:
|
||||
raise exceptions.rest.RequestError(_('Item is not removable')) from None
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
|
||||
log.log(userservice, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
|
||||
|
||||
# Only owner is allowed to change right now
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
if not item:
|
||||
raise exceptions.rest.RequestError('Only modify is allowed')
|
||||
fields = self.fields_from_params(['auth_id:_', 'user_id:_', 'ip:_'])
|
||||
|
||||
userservice = parent.userServices.get(uuid=process_uuid(item))
|
||||
if 'user_id' in fields and 'auth_id' in fields:
|
||||
user = models.User.objects.get(uuid=process_uuid(fields['user_id']))
|
||||
|
||||
log_string = f'Changed ownership of user service {userservice.friendly_name} from {userservice.user} to {user.pretty_name} by {self._user.pretty_name}'
|
||||
|
||||
# If there is another service that has this same owner, raise an exception
|
||||
if (
|
||||
parent.userServices.filter(user=user)
|
||||
.exclude(uuid=userservice.uuid)
|
||||
.exclude(state__in=State.INFO_STATES)
|
||||
.count()
|
||||
> 0
|
||||
):
|
||||
raise exceptions.rest.RequestError(
|
||||
f'There is already another user service assigned to {user.pretty_name}'
|
||||
)
|
||||
|
||||
userservice.user = user
|
||||
userservice.save()
|
||||
elif 'ip' in fields:
|
||||
log_string = f'Changed IP of user service {userservice.friendly_name} to {fields["ip"]} by {self._user.pretty_name}'
|
||||
userservice.log_ip(fields['ip'])
|
||||
else:
|
||||
raise exceptions.rest.RequestError('Invalid fields')
|
||||
|
||||
# Log change
|
||||
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
|
||||
log.log(userservice, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
|
||||
|
||||
return {'id': userservice.uuid}
|
||||
|
||||
def reset(self, parent: 'models.ServicePool', item: str) -> typing.Any:
|
||||
userservice = parent.userServices.get(uuid=process_uuid(item))
|
||||
UserServiceManager.manager().reset(userservice)
|
||||
|
||||
|
||||
class CachedService(AssignedUserService):
|
||||
"""
|
||||
Rest handler for Cached Services, which parent is ServicePool
|
||||
"""
|
||||
|
||||
CUSTOM_METHODS = [] # Remove custom methods from assigned services
|
||||
|
||||
def get_item_position(self, parent: Model, item_uuid: str) -> int:
|
||||
return self.do_get_item_position(for_cached=True, parent=parent, item_uuid=item_uuid)
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult['UserServiceItem']:
|
||||
return self.do_get_items(parent, for_cached=True)
|
||||
|
||||
def get_item(
|
||||
self,
|
||||
parent: 'Model',
|
||||
item: str,
|
||||
) -> 'UserServiceItem':
|
||||
return self.do_get_item(parent, item, for_cached=True)
|
||||
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
table_info = (
|
||||
ui_utils.TableBuilder(_('Cached Services'))
|
||||
.datetime_column(name='creation_date', title=_('Creation date'))
|
||||
.text_column(name='revision', title=_('Revision'))
|
||||
.text_column(name='unique_id', title='Unique ID')
|
||||
.text_column(name='ip', title=_('IP'))
|
||||
.text_column(name='friendly_name', title=_('Friendly name'))
|
||||
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
|
||||
.with_field_mappings(revision='deployed_service.publications.revision')
|
||||
.with_filter_fields('creation_date', 'unique_id', 'friendly_name', 'state')
|
||||
)
|
||||
if parent.state != State.LOCKED:
|
||||
table_info = table_info.text_column(name='cache_level', title=_('Cache level')).text_column(
|
||||
name='actor_version', title=_('Actor version')
|
||||
)
|
||||
|
||||
return table_info.build()
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str, cache: bool = False) -> None:
|
||||
return super().delete_item(parent, item, cache=True)
|
||||
|
||||
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
try:
|
||||
userservice = parent.cached_users_services().get(uuid=process_uuid(item))
|
||||
logger.debug('Getting logs for %s', item)
|
||||
return log.get_logs(userservice)
|
||||
except Exception as e:
|
||||
logger.error('Error getting user service logs for %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user service logs')) from None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class GroupItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
auth_id: str
|
||||
name: str
|
||||
group_name: str
|
||||
comments: str
|
||||
state: str
|
||||
type: str
|
||||
auth_name: str
|
||||
|
||||
|
||||
class Groups(DetailHandler[GroupItem]):
|
||||
"""
|
||||
Processes the groups detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult['GroupItem']:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
|
||||
return [
|
||||
GroupItem(
|
||||
id=group.uuid,
|
||||
auth_id=group.manager.uuid,
|
||||
name=group.name,
|
||||
group_name=group.pretty_name,
|
||||
comments=group.comments,
|
||||
state=group.state,
|
||||
type='meta' if group.is_meta else 'group',
|
||||
auth_name=group.manager.name,
|
||||
)
|
||||
for group in typing.cast(
|
||||
collections.abc.Iterable[models.Group], self.filter_odata_queryset(parent.assignedGroups.all())
|
||||
)
|
||||
]
|
||||
|
||||
def get_item(self, parent: Model, item: str) -> GroupItem:
|
||||
raise exceptions.rest.NotSupportedError('Single group retrieval not implemented inside assigned groups')
|
||||
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Assigned groups'))
|
||||
.text_column(name='group_name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('comments'))
|
||||
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
|
||||
group: models.Group = models.Group.objects.get(uuid=process_uuid(self._params['id']))
|
||||
parent.assignedGroups.add(group)
|
||||
log.log(
|
||||
parent,
|
||||
types.log.LogLevel.INFO,
|
||||
f'Added group {group.pretty_name} by {self._user.pretty_name}',
|
||||
types.log.LogSource.ADMIN,
|
||||
)
|
||||
|
||||
return {'id': group.uuid}
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
group: models.Group = models.Group.objects.get(uuid=process_uuid(self._args[0]))
|
||||
parent.assignedGroups.remove(group)
|
||||
log.log(
|
||||
parent,
|
||||
types.log.LogLevel.INFO,
|
||||
f'Removed group {group.pretty_name} by {self._user.pretty_name}',
|
||||
types.log.LogSource.ADMIN,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TransportItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
type: dict[str, typing.Any] # TypeInfo
|
||||
comments: str
|
||||
priority: int
|
||||
trans_type: str
|
||||
|
||||
|
||||
class Transports(DetailHandler[TransportItem]):
|
||||
"""
|
||||
Processes the transports detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult['TransportItem']:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
|
||||
return [
|
||||
TransportItem(
|
||||
id=trans.uuid,
|
||||
name=trans.name,
|
||||
type=type(self).as_typeinfo(trans.get_type()).as_dict(),
|
||||
comments=trans.comments,
|
||||
priority=trans.priority,
|
||||
trans_type=trans.get_type().mod_name(),
|
||||
)
|
||||
for trans in self.filter_odata_queryset(parent.transports.all())
|
||||
]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> TransportItem:
|
||||
raise exceptions.rest.NotSupportedError(
|
||||
'Single transport retrieval not implemented inside assigned transports'
|
||||
)
|
||||
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Assigned transports'))
|
||||
.numeric_column(name='priority', title=_('Priority'), width='6em')
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='trans_type', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
transport: models.Transport = models.Transport.objects.get(uuid=process_uuid(self._params['id']))
|
||||
parent.transports.add(transport)
|
||||
log.log(
|
||||
parent,
|
||||
types.log.LogLevel.INFO,
|
||||
f'Added transport {transport.name} by {self._user.pretty_name}',
|
||||
types.log.LogSource.ADMIN,
|
||||
)
|
||||
|
||||
return {'id': transport.uuid}
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
transport: models.Transport = models.Transport.objects.get(uuid=process_uuid(self._args[0]))
|
||||
parent.transports.remove(transport)
|
||||
log.log(
|
||||
parent,
|
||||
types.log.LogLevel.INFO,
|
||||
f'Removed transport {transport.name} by {self._user.pretty_name}',
|
||||
types.log.LogSource.ADMIN,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PublicationItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
revision: int
|
||||
publish_date: datetime.datetime
|
||||
state: str
|
||||
reason: str
|
||||
state_date: datetime.datetime
|
||||
|
||||
|
||||
class Publications(DetailHandler[PublicationItem]):
|
||||
"""
|
||||
Processes the publications detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
CUSTOM_METHODS = ['publish', 'cancel'] # We provided these custom methods
|
||||
|
||||
def publish(self, parent: 'Model') -> typing.Any:
|
||||
"""
|
||||
Custom method "publish", provided to initiate a publication of a deployed service
|
||||
:param parent: Parent service pool
|
||||
"""
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
change_log = self._params['changelog'] if 'changelog' in self._params else None
|
||||
|
||||
if (
|
||||
permissions.has_access(self._user, parent, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
is False
|
||||
):
|
||||
logger.debug('Management Permission failed for user %s', self._user)
|
||||
raise exceptions.rest.AccessDenied(_('Access denied to publish service pool')) from None
|
||||
|
||||
logger.debug('Custom "publish" invoked for %s', parent)
|
||||
parent.publish(change_log) # Can raise exceptions that will be processed on response
|
||||
|
||||
log.log(
|
||||
parent,
|
||||
types.log.LogLevel.INFO,
|
||||
f'Initiated publication v{parent.current_pub_revision} by {self._user.pretty_name}',
|
||||
types.log.LogSource.ADMIN,
|
||||
)
|
||||
|
||||
return self.success()
|
||||
|
||||
def cancel(self, parent: 'Model', uuid: str) -> typing.Any:
|
||||
"""
|
||||
Invoked to cancel a running publication
|
||||
Double invocation (this means, invoking cancel twice) will mean that is a "forced cancelation"
|
||||
:param parent: Parent service pool
|
||||
:param uuid: uuid of the publication
|
||||
"""
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
if (
|
||||
permissions.has_access(self._user, parent, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
is False
|
||||
):
|
||||
logger.debug('Management Permission failed for user %s', self._user)
|
||||
raise exceptions.rest.AccessDenied(_('Access denied to cancel service pool publication')) from None
|
||||
|
||||
try:
|
||||
ds = models.ServicePoolPublication.objects.get(uuid=process_uuid(uuid))
|
||||
ds.cancel()
|
||||
except Exception as e:
|
||||
raise exceptions.rest.ResponseError(str(e)) from e
|
||||
|
||||
log.log(
|
||||
parent,
|
||||
types.log.LogLevel.INFO,
|
||||
f'Canceled publication v{parent.current_pub_revision} by {self._user.pretty_name}',
|
||||
types.log.LogSource.ADMIN,
|
||||
)
|
||||
|
||||
return self.success()
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult['PublicationItem']:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return [
|
||||
PublicationItem(
|
||||
id=i.uuid,
|
||||
revision=i.revision,
|
||||
publish_date=i.publish_date,
|
||||
state=i.state,
|
||||
reason=State.from_str(i.state).is_errored() and i.get_instance().error_reason() or '',
|
||||
state_date=i.state_date,
|
||||
)
|
||||
for i in self.filter_odata_queryset(parent.publications.all())
|
||||
]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> PublicationItem:
|
||||
raise exceptions.rest.NotSupportedError(
|
||||
'Single publication retrieval not implemented inside assigned publications'
|
||||
)
|
||||
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Publications'))
|
||||
.numeric_column(name='revision', title=_('Revision'), width='6em')
|
||||
.datetime_column(name='publish_date', title=_('Publish date'))
|
||||
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
|
||||
.text_column(name='reason', title=_('Reason'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
).build()
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ChangelogItem(types.rest.BaseRestItem):
|
||||
revision: int
|
||||
stamp: datetime.datetime
|
||||
log: str
|
||||
|
||||
|
||||
class Changelog(DetailHandler[ChangelogItem]):
|
||||
"""
|
||||
Processes the transports detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult['ChangelogItem']:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return [
|
||||
ChangelogItem(
|
||||
revision=i.revision,
|
||||
stamp=i.stamp,
|
||||
log=i.log,
|
||||
)
|
||||
for i in self.filter_odata_queryset(parent.changelog.all())
|
||||
]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> ChangelogItem:
|
||||
raise exceptions.rest.NotSupportedError('Single changelog retrieval not implemented inside changelog')
|
||||
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Changelog'))
|
||||
.numeric_column(name='revision', title=_('Revision'), width='6em')
|
||||
.datetime_column(name='stamp', title=_('Publish date'))
|
||||
.text_column(name='log', title=_('Comment'))
|
||||
).build()
|
||||
@@ -1,558 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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
|
||||
import collections.abc
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Model
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from uds.core.types.states import State
|
||||
|
||||
from uds.core.auths.user import User as AUser
|
||||
from uds.core.util import log, ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid, sql_stamp_seconds
|
||||
from uds.models import Authenticator, User, Group, ServicePool, UserService
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core import consts, exceptions, types
|
||||
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
from .user_services import AssignedUserService, UserServiceItem
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Details of /auth
|
||||
|
||||
|
||||
def get_groups_from_metagroup(groups: collections.abc.Iterable[Group]) -> collections.abc.Iterable[Group]:
|
||||
for g in groups:
|
||||
if g.is_meta:
|
||||
for x in g.groups.all():
|
||||
yield x
|
||||
else:
|
||||
yield g
|
||||
|
||||
|
||||
def get_service_pools_for_groups(
|
||||
groups: collections.abc.Iterable[Group],
|
||||
) -> collections.abc.Iterable[ServicePool]:
|
||||
for servicepool in ServicePool.get_pools_for_groups(groups):
|
||||
yield servicepool
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class UserItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
real_name: str
|
||||
comments: str
|
||||
state: str
|
||||
staff_member: bool
|
||||
is_admin: bool
|
||||
last_access: datetime.datetime
|
||||
mfa_data: str
|
||||
role: str
|
||||
parent: str | None
|
||||
groups: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
class Users(DetailHandler[UserItem]):
|
||||
CUSTOM_METHODS = [
|
||||
'services_pools',
|
||||
'user_services',
|
||||
'clean_related',
|
||||
'add_to_group',
|
||||
'enable_client_logging',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def as_user_item(user: 'User') -> UserItem:
|
||||
return UserItem(
|
||||
id=user.uuid,
|
||||
name=user.name,
|
||||
real_name=user.real_name,
|
||||
comments=user.comments,
|
||||
state=user.state,
|
||||
staff_member=user.staff_member,
|
||||
is_admin=user.is_admin,
|
||||
last_access=user.last_access,
|
||||
mfa_data=user.mfa_data,
|
||||
parent=user.parent,
|
||||
groups=[i.uuid for i in user.get_groups()],
|
||||
role=user.get_role().as_str(),
|
||||
)
|
||||
|
||||
def get_item_position(self, parent: 'Model', item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
|
||||
return self.calc_item_position(item_uuid, parent.users.all())
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult[UserItem]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
|
||||
# Extract authenticator
|
||||
return [self.as_user_item(i) for i in self.odata_filter(parent.users.all())]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> UserItem:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
|
||||
db_usr = parent.users.get(uuid__iexact=process_uuid(item))
|
||||
user_item = self.as_user_item(db_usr)
|
||||
auth_usr = AUser(db_usr)
|
||||
user_item.groups = [g.db_obj().uuid for g in auth_usr.groups()]
|
||||
return user_item
|
||||
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Users of {0}').format(parent.name))
|
||||
.icon(name='name', title=_('Username'), visible=True)
|
||||
.text_column(name='role', title=_('Role'))
|
||||
.text_column(name='real_name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.dict_column(
|
||||
name='state', title=_('Status'), dct={State.ACTIVE: _('Enabled'), State.INACTIVE: _('Disabled')}
|
||||
)
|
||||
.datetime_column(name='last_access', title=_('Last access'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
).build()
|
||||
|
||||
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
user = None
|
||||
try:
|
||||
user = parent.users.get(uuid=process_uuid(item))
|
||||
except User.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting user %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user')) from e
|
||||
|
||||
return log.get_logs(user)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
logger.debug('Saving user %s / %s', parent, item)
|
||||
valid_fields = [
|
||||
'name',
|
||||
'real_name',
|
||||
'comments',
|
||||
'state',
|
||||
'staff_member',
|
||||
'is_admin',
|
||||
]
|
||||
if self._params.get('name', '').strip() == '':
|
||||
raise exceptions.rest.RequestError(_('Username cannot be empty'))
|
||||
|
||||
if 'password' in self._params:
|
||||
valid_fields.append('password')
|
||||
self._params['password'] = CryptoManager().hash(self._params['password'])
|
||||
|
||||
if 'mfa_data' in self._params:
|
||||
valid_fields.append('mfa_data')
|
||||
self._params['mfa_data'] = self._params['mfa_data'].strip()
|
||||
|
||||
fields = self.fields_from_params(valid_fields)
|
||||
if not self._user.is_admin:
|
||||
del fields['staff_member']
|
||||
del fields['is_admin']
|
||||
|
||||
user = None
|
||||
try:
|
||||
with transaction.atomic():
|
||||
auth = parent.get_instance()
|
||||
if item is None: # Create new
|
||||
auth.create_user(
|
||||
fields
|
||||
) # this throws an exception if there is an error (for example, this auth can't create users)
|
||||
user = parent.users.create(**fields)
|
||||
else:
|
||||
auth.modify_user(fields) # Notifies authenticator
|
||||
user = parent.users.get(uuid=process_uuid(item))
|
||||
user.__dict__.update(fields)
|
||||
user.save()
|
||||
|
||||
logger.debug('User parent: %s', user.parent)
|
||||
# If internal auth, and not a child user, save groups
|
||||
if not auth.external_source and not user.parent:
|
||||
groups = self.fields_from_params(['groups'])['groups']
|
||||
# Save but skip meta groups, they are not real groups, but just a way to group users based on rules
|
||||
user.groups.set(g for g in parent.groups.filter(uuid__in=groups) if g.is_meta is False)
|
||||
|
||||
return {'id': user.uuid}
|
||||
except User.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User not found')) from None
|
||||
except IntegrityError: # Duplicate key probably
|
||||
raise exceptions.rest.RequestError(_('User already exists (duplicate key error)')) from None
|
||||
except ValidationError as e:
|
||||
raise exceptions.rest.RequestError(str(e.message)) from e
|
||||
except exceptions.auth.AuthenticatorException as e:
|
||||
raise exceptions.rest.RequestError(str(e)) from e
|
||||
except exceptions.rest.RequestError:
|
||||
raise # Re-raise
|
||||
except Exception as e:
|
||||
logger.error('Error saving user %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error saving user')) from e
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
try:
|
||||
user = parent.users.get(uuid=process_uuid(item))
|
||||
if not self._user.is_admin and (user.is_admin or user.staff_member):
|
||||
logger.warning(
|
||||
'Removal of user %s denied due to insufficients rights',
|
||||
user.pretty_name,
|
||||
)
|
||||
raise exceptions.rest.AccessDenied(
|
||||
f'Removal of user {user.pretty_name} denied due to insufficients rights'
|
||||
)
|
||||
|
||||
assigned_userservice: 'UserService'
|
||||
for assigned_userservice in user.userServices.all():
|
||||
try:
|
||||
assigned_userservice.user = None
|
||||
assigned_userservice.save(update_fields=['user'])
|
||||
assigned_userservice.remove_or_cancel()
|
||||
except Exception:
|
||||
logger.exception('Removing user service')
|
||||
try:
|
||||
assigned_userservice.save()
|
||||
except Exception:
|
||||
logger.exception('Saving user on removing error')
|
||||
|
||||
user.delete()
|
||||
except User.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error on user removal of %s.%s: %s', parent.name, item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error removing user')) from e
|
||||
|
||||
def services_pools(self, parent: 'Model', item: str) -> list[dict[str, typing.Any]]:
|
||||
"""
|
||||
API:
|
||||
Returns the service pools assigned to a user
|
||||
"""
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
uuid = process_uuid(item)
|
||||
user = parent.users.get(uuid=process_uuid(uuid))
|
||||
res: list[dict[str, typing.Any]] = []
|
||||
groups = list(user.get_groups())
|
||||
for i in get_service_pools_for_groups(groups):
|
||||
res.append(
|
||||
{
|
||||
'id': i.uuid,
|
||||
'name': i.name,
|
||||
'thumb': i.image.thumb64 if i.image is not None else consts.images.DEFAULT_THUMB_BASE64,
|
||||
'user_services_count': i.userServices.exclude(
|
||||
state__in=(State.REMOVED, State.ERROR)
|
||||
).count(),
|
||||
'state': _('With errors') if i.is_restrained() else _('Ok'),
|
||||
}
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
def user_services(self, parent: 'Authenticator', item: str) -> list[UserServiceItem]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
uuid = process_uuid(item)
|
||||
user = parent.users.get(uuid=process_uuid(uuid))
|
||||
|
||||
def item_as_dict(assigned_user_service: 'UserService') -> UserServiceItem:
|
||||
base = AssignedUserService.userservice_item(assigned_user_service)
|
||||
base.pool_name = assigned_user_service.deployed_service.name
|
||||
base.pool_id = assigned_user_service.deployed_service.uuid
|
||||
return base
|
||||
|
||||
return [
|
||||
item_as_dict(i)
|
||||
for i in user.userServices.all().prefetch_related('deployed_service').filter(state=State.USABLE)
|
||||
]
|
||||
|
||||
def clean_related(self, parent: 'Authenticator', item: str) -> dict[str, str]:
|
||||
uuid = process_uuid(item)
|
||||
user = parent.users.get(uuid=process_uuid(uuid))
|
||||
user.clean_related_data()
|
||||
return {'status': 'ok'}
|
||||
|
||||
def add_to_group(self, parent: 'Authenticator', item: str) -> dict[str, str]:
|
||||
uuid = process_uuid(item)
|
||||
user = parent.users.get(uuid=process_uuid(uuid))
|
||||
group = parent.groups.get(uuid=process_uuid(self._params['group']))
|
||||
user.log(
|
||||
f'Added to group {group.name} by {self._user.pretty_name}',
|
||||
types.log.LogLevel.INFO,
|
||||
types.log.LogSource.REST,
|
||||
)
|
||||
user.groups.add(group)
|
||||
return {'status': 'ok'}
|
||||
|
||||
def enable_client_logging(self, parent: 'Model', item: str) -> dict[str, str]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
user = parent.users.get(uuid=process_uuid(item))
|
||||
user.log(
|
||||
f'Client logging enabled by {self._user.pretty_name}',
|
||||
types.log.LogLevel.INFO,
|
||||
types.log.LogSource.REST,
|
||||
)
|
||||
with user.properties as props:
|
||||
props['client_logging'] = sql_stamp_seconds()
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class GroupItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
comments: str
|
||||
state: str
|
||||
type: str
|
||||
meta_if_any: bool
|
||||
skip_mfa: str
|
||||
groups: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
pools: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
class Groups(DetailHandler[GroupItem]):
|
||||
CUSTOM_METHODS = ['services_pools', 'users']
|
||||
|
||||
@staticmethod
|
||||
def as_group_item(group: 'Group') -> GroupItem:
|
||||
val = GroupItem(
|
||||
id=group.uuid,
|
||||
name=group.name,
|
||||
comments=group.comments,
|
||||
state=group.state,
|
||||
type=group.is_meta and 'meta' or 'group',
|
||||
meta_if_any=group.meta_if_any,
|
||||
skip_mfa=group.skip_mfa,
|
||||
)
|
||||
if group.is_meta:
|
||||
val.groups = list(x.uuid for x in group.groups.all().order_by('name'))
|
||||
return val
|
||||
|
||||
def get_item_position(self, parent: 'Model', item_uuid: str) -> int:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
return self.calc_item_position(item_uuid, parent.groups.all())
|
||||
|
||||
def get_items(self, parent: 'Model') -> types.rest.ItemsResult['GroupItem']:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
q = self.odata_filter(parent.groups.all())
|
||||
return [self.as_group_item(i) for i in q]
|
||||
|
||||
def get_item(self, parent: 'Model', item: str) -> 'GroupItem':
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
db_grp = parent.groups.filter(uuid=process_uuid(item)).first()
|
||||
if not db_grp:
|
||||
raise exceptions.rest.NotFound(_('Group not found')) from None
|
||||
grp = self.as_group_item(db_grp)
|
||||
grp.pools = [v.uuid for v in get_service_pools_for_groups([db_grp])]
|
||||
return grp
|
||||
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Groups of {0}').format(parent.name))
|
||||
.text_column(name='name', title=_('Group'), visible=True)
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.dict_column(name='state', title=_('Status'), dct=State.literals_dict())
|
||||
.dict_column(name='skip_mfa', title=_('Skip MFA'), dct=State.literals_dict())
|
||||
).build()
|
||||
|
||||
def enum_types(
|
||||
self, parent: 'Model', for_type: typing.Optional[str]
|
||||
) -> collections.abc.Iterable[types.rest.TypeInfo]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
types_dict: dict[str, dict[str, str]] = {
|
||||
'group': {'name': _('Group'), 'description': _('UDS Group')},
|
||||
'meta': {'name': _('Meta group'), 'description': _('UDS Meta Group')},
|
||||
}
|
||||
types_list: list[types.rest.TypeInfo] = [
|
||||
types.rest.TypeInfo(
|
||||
name=v['name'],
|
||||
type=k,
|
||||
description=v['description'],
|
||||
icon='',
|
||||
)
|
||||
for k, v in types_dict.items()
|
||||
]
|
||||
|
||||
if not for_type:
|
||||
return types_list
|
||||
|
||||
try:
|
||||
return [next(filter(lambda x: x.type == for_type, types_list))]
|
||||
except StopIteration:
|
||||
logger.error('Type %s not found in %s', for_type, types_list)
|
||||
raise exceptions.rest.NotFound(_('Group type not found')) from None
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
group = None # Avoid warning on reference before assignment
|
||||
try:
|
||||
is_meta = self._params['type'] == 'meta'
|
||||
meta_if_any = self._params.get('meta_if_any', False)
|
||||
pools = self._params.get('pools', None)
|
||||
skip_check = self._params.get('skip_check', False)
|
||||
logger.debug('Saving group %s / %s', parent, item)
|
||||
logger.debug('Meta any %s', meta_if_any)
|
||||
logger.debug('Pools: %s', pools)
|
||||
logger.debug('Skip check: %s', skip_check)
|
||||
valid_fields = ['name', 'comments', 'state', 'skip_mfa']
|
||||
if self._params.get('name', '') == '':
|
||||
raise exceptions.rest.RequestError(_('Group name is required'))
|
||||
fields = self.fields_from_params(valid_fields)
|
||||
is_pattern = fields.get('name', '').find('pat:') == 0
|
||||
auth = parent.get_instance()
|
||||
to_save: dict[str, typing.Any] = {}
|
||||
if not item: # Create new
|
||||
if not is_meta and not is_pattern and not skip_check:
|
||||
auth.create_group(
|
||||
fields
|
||||
) # this throws an exception if there is an error (for example, this auth can't create groups)
|
||||
for k in valid_fields:
|
||||
to_save[k] = fields[k]
|
||||
to_save['comments'] = fields['comments'][:255]
|
||||
to_save['is_meta'] = is_meta
|
||||
to_save['meta_if_any'] = meta_if_any
|
||||
group = parent.groups.create(**to_save)
|
||||
else:
|
||||
if not is_meta and not is_pattern:
|
||||
auth.modify_group(fields)
|
||||
for k in valid_fields:
|
||||
to_save[k] = fields[k]
|
||||
del to_save['name'] # Name can't be changed
|
||||
to_save['comments'] = fields['comments'][:255]
|
||||
to_save['meta_if_any'] = meta_if_any
|
||||
to_save['skip_mfa'] = fields['skip_mfa']
|
||||
|
||||
group = parent.groups.get(uuid=process_uuid(item))
|
||||
group.__dict__.update(to_save)
|
||||
|
||||
if is_meta:
|
||||
# Do not allow to add meta groups to meta groups
|
||||
group.groups.set(
|
||||
i for i in parent.groups.filter(uuid__in=self._params['groups']) if i.is_meta is False
|
||||
)
|
||||
|
||||
if pools:
|
||||
# Update pools
|
||||
group.deployedServices.set(ServicePool.objects.filter(uuid__in=pools))
|
||||
|
||||
group.save()
|
||||
return {'id': group.uuid}
|
||||
except Group.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Group not found')) from None
|
||||
except IntegrityError: # Duplicate key probably
|
||||
raise exceptions.rest.RequestError(_('User already exists (duplicate key error)')) from None
|
||||
except exceptions.auth.AuthenticatorException as e:
|
||||
raise exceptions.rest.RequestError(str(e)) from e
|
||||
except exceptions.rest.RequestError: # pylint: disable=try-except-raise
|
||||
raise # Re-raise
|
||||
except Exception as e:
|
||||
logger.error('Error saving group %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error saving group')) from e
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
try:
|
||||
group = parent.groups.get(uuid=item)
|
||||
|
||||
group.delete()
|
||||
except exceptions.rest.NotFound:
|
||||
raise exceptions.rest.NotFound(_('Group not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error deleting group %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error deleting group')) from e
|
||||
|
||||
def services_pools(self, parent: 'Model', item: str) -> list[collections.abc.Mapping[str, typing.Any]]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
uuid = process_uuid(item)
|
||||
group = parent.groups.get(uuid=process_uuid(uuid))
|
||||
res: list[collections.abc.Mapping[str, typing.Any]] = []
|
||||
for i in get_service_pools_for_groups((group,)):
|
||||
res.append(
|
||||
{
|
||||
'id': i.uuid,
|
||||
'name': i.name,
|
||||
'thumb': i.image.thumb64 if i.image is not None else consts.images.DEFAULT_THUMB_BASE64,
|
||||
'user_services_count': i.userServices.exclude(
|
||||
state__in=(State.REMOVED, State.ERROR)
|
||||
).count(),
|
||||
'state': _('With errors') if i.is_restrained() else _('Ok'),
|
||||
}
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
def users(self, parent: 'Model', item: str) -> list[collections.abc.Mapping[str, typing.Any]]:
|
||||
uuid = process_uuid(item)
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
group = parent.groups.get(uuid=process_uuid(uuid))
|
||||
|
||||
def info(user: 'User') -> dict[str, typing.Any]:
|
||||
return {
|
||||
'id': user.uuid,
|
||||
'name': user.name,
|
||||
'real_name': user.real_name,
|
||||
'state': user.state,
|
||||
'last_access': user.last_access,
|
||||
}
|
||||
|
||||
if group.is_meta:
|
||||
# Get all users for everygroup and
|
||||
groups = get_groups_from_metagroup((group,))
|
||||
users_set: typing.Optional[set['User']] = None
|
||||
for g in groups:
|
||||
current_set: set['User'] = set((i for i in g.users.all()))
|
||||
if users_set is None:
|
||||
users_set = current_set
|
||||
else:
|
||||
if group.meta_if_any:
|
||||
users_set |= current_set
|
||||
else:
|
||||
users_set &= current_set
|
||||
|
||||
if not users_set:
|
||||
break # If already empty, stop
|
||||
users = list(users_set or {}) if users_set else []
|
||||
users_set = None
|
||||
else:
|
||||
users = list(group.users.all())
|
||||
|
||||
return [info(i) for i in users]
|
||||
@@ -1,47 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2022 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 logging
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
from uds.core import consts
|
||||
|
||||
from ..handlers import Handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UDSVersion(Handler):
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
NAME = 'version'
|
||||
|
||||
def get(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
return {'version': consts.system.VERSION, 'build': consts.system.VERSION_STAMP}
|
||||
@@ -1,35 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-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
|
||||
"""
|
||||
# pyright: reportUnusedImport=false
|
||||
from .base import BaseModelHandler
|
||||
from .detail import DetailHandler
|
||||
from .master import ModelHandler
|
||||
@@ -1,208 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-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 abc
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core import exceptions
|
||||
from uds.core import types
|
||||
from uds.core.util import permissions
|
||||
from uds.core.module import Module
|
||||
|
||||
# from uds.models import ManagedObjectModel
|
||||
|
||||
from ..handlers import Handler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
class BaseModelHandler(Handler, abc.ABC, typing.Generic[types.rest.T_Item]):
|
||||
"""
|
||||
Base Handler for Master & Detail Handlers
|
||||
"""
|
||||
|
||||
def check_access(
|
||||
self,
|
||||
obj: models.Model,
|
||||
permission: 'types.permissions.PermissionType',
|
||||
root: bool = False,
|
||||
) -> None:
|
||||
if not permissions.has_access(self._user, obj, permission, root):
|
||||
raise exceptions.rest.AccessDenied('Access denied')
|
||||
|
||||
def get_permissions(self, obj: models.Model, root: bool = False) -> int:
|
||||
return permissions.effective_permissions(self._user, obj, root)
|
||||
|
||||
@classmethod
|
||||
def extra_type_info(cls: type[typing.Self], type_: type['Module']) -> types.rest.ExtraTypeInfo | None:
|
||||
"""
|
||||
Returns info about the type
|
||||
In fact, right now, it returns an empty dict, that will be extended by typeAsDict
|
||||
"""
|
||||
return None
|
||||
|
||||
@typing.final
|
||||
@classmethod
|
||||
def as_typeinfo(cls: type[typing.Self], type_: type['Module']) -> types.rest.TypeInfo:
|
||||
"""
|
||||
Returns a dictionary describing the type (the name, the icon, description, etc...)
|
||||
"""
|
||||
return types.rest.TypeInfo(
|
||||
name=_(type_.mod_name()),
|
||||
type=type_.mod_type(),
|
||||
description=_(type_.description()),
|
||||
icon=type_.icon64().replace('\n', ''),
|
||||
extra=cls.extra_type_info(type_),
|
||||
group=getattr(type_, 'group', None),
|
||||
)
|
||||
|
||||
def fields_from_params(
|
||||
self, fields_list: list[str], *, defaults: dict[str, typing.Any] | None = None
|
||||
) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Reads the indicated fields from the parameters received, and if
|
||||
:param fields_list: List of required fields
|
||||
:return: A dictionary containing all required fields
|
||||
"""
|
||||
args: dict[str, str] = {}
|
||||
default: str | None = None
|
||||
try:
|
||||
for key in fields_list:
|
||||
# if : is in the field, it is an optional field, with an "static" default value
|
||||
if ':' in key: # optional field? get default if not present
|
||||
k, default = key.split(':')[:2]
|
||||
# Convert "None" to None
|
||||
default = None if default == 'None' else default
|
||||
# If key is not present, and default = _, then it is not required skip it
|
||||
if default == '_' and k not in self._params:
|
||||
continue
|
||||
args[k] = self._params.get(k, default)
|
||||
else: # Required field, with a possible default on defaults dict
|
||||
if key not in self._params:
|
||||
if defaults and key in defaults:
|
||||
args[key] = defaults[key]
|
||||
else:
|
||||
raise exceptions.rest.RequestError(f'needed parameter not found in data {key}')
|
||||
else:
|
||||
# Set the value
|
||||
args[key] = self._params[key]
|
||||
|
||||
# del self._params[key]
|
||||
except KeyError as e:
|
||||
raise exceptions.rest.RequestError(f'needed parameter not found in data {e}')
|
||||
|
||||
return args
|
||||
|
||||
def odata_filter(self, qs: models.QuerySet[T]) -> list[T]:
|
||||
"""
|
||||
Invoked to filter the queryset according to parameters received
|
||||
Default implementation does not filter anything
|
||||
|
||||
Args:
|
||||
qs: Queryset to filter
|
||||
|
||||
Returns:
|
||||
Filtered queryset as a list
|
||||
|
||||
Note:
|
||||
This is not final, so we can override it in subclasses if needed
|
||||
"""
|
||||
return self.filter_odata_queryset(qs)
|
||||
|
||||
# Success methods
|
||||
def success(self) -> str:
|
||||
"""
|
||||
Utility method to be invoked for simple methods that returns a simple OK response
|
||||
"""
|
||||
logger.debug('Returning success on %s %s', self.__class__, self._args)
|
||||
return consts.OK
|
||||
|
||||
def test(self, type_: str) -> str:
|
||||
"""
|
||||
Invokes a test for an item
|
||||
"""
|
||||
logger.debug('Called base test for %s --> %s', self.__class__.__name__, self._params)
|
||||
raise exceptions.rest.NotSupportedError(_('Testing not supported'))
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
"""
|
||||
Default implementation does not have any component types. (for Api specification purposes)
|
||||
"""
|
||||
return types.rest.api.Components()
|
||||
|
||||
@classmethod
|
||||
def api_paths(
|
||||
cls: type[typing.Self], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
return {}
|
||||
|
||||
@typing.final
|
||||
@staticmethod
|
||||
def common_components() -> types.rest.api.Components:
|
||||
"""
|
||||
Returns a list of common components for the API for ModelHandlers (Model and Detail)
|
||||
"""
|
||||
from uds.core.util import api as api_utils
|
||||
|
||||
return (
|
||||
api_utils.api_components(types.rest.TypeInfo)
|
||||
| api_utils.api_components(types.rest.TableInfo)
|
||||
| api_utils.api_components(
|
||||
types.ui.GuiElement,
|
||||
removable_fields=['value', 'gui.old_field_name', 'gui.value', 'gui.field_name'],
|
||||
)
|
||||
)
|
||||
|
||||
@typing.final
|
||||
@staticmethod
|
||||
def common_paths() -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns a dictionary of common paths for the API for ModelHandlers (Model and Detail)
|
||||
"""
|
||||
return {}
|
||||
@@ -1,418 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-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
|
||||
"""
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
import logging
|
||||
import typing
|
||||
import collections.abc
|
||||
import abc
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts, exceptions, types, module
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.util import api as api_utils, model as model_utils
|
||||
from uds.REST.utils import rest_result
|
||||
|
||||
from uds.REST.model.base import BaseModelHandler
|
||||
from uds.REST.utils import camel_and_snake_case_from
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
from uds.models import User
|
||||
from uds.REST.model.master import ModelHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Details do not have types at all
|
||||
# so, right now, we only process details petitions for Handling & tables info
|
||||
# noinspection PyMissingConstructor
|
||||
|
||||
|
||||
class DetailHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
|
||||
"""
|
||||
Detail handler (for relations such as provider-->services, authenticators-->users,groups, deployed services-->cache,assigned, groups, transports
|
||||
Urls recognized for GET are:
|
||||
[path] --> get Items (all items, this call is delegated to get_items)
|
||||
[path]/overview
|
||||
[path]/ID
|
||||
[path]/gui
|
||||
[path]/gui/TYPE
|
||||
[path]/types
|
||||
[path]/types/TYPE
|
||||
[path]/tableinfo
|
||||
....?filter=[filter],[filter]..., filters are simple unix files filters, with ^ and $ supported
|
||||
For PUT:
|
||||
[path] --> create NEW item
|
||||
[path]/ID --> Modify existing item
|
||||
For DELETE:
|
||||
[path]/ID
|
||||
|
||||
Also accepts GET methods for "custom" methods
|
||||
"""
|
||||
|
||||
CUSTOM_METHODS: typing.ClassVar[list[str]] = []
|
||||
_parent: typing.Optional[
|
||||
'ModelHandler[types.rest.T_Item]'
|
||||
] # Parent handler, that is the ModelHandler that contains this detail
|
||||
_path: str
|
||||
_params: typing.Any # _params is deserialized object from request
|
||||
_args: list[str]
|
||||
_parent_item: models.Model # Parent item, that is the parent model element
|
||||
_user: 'User'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent_handler: 'ModelHandler[types.rest.T_Item]',
|
||||
path: str,
|
||||
params: typing.Any,
|
||||
*args: str,
|
||||
user: 'User',
|
||||
parent_item: models.Model,
|
||||
) -> None:
|
||||
"""
|
||||
Detail Handlers in fact "disabled" handler most initialization, that is no needed because
|
||||
parent modelhandler has already done it (so we must access through parent handler)
|
||||
"""
|
||||
# Parent init not invoked because their methos are not used on detail handlers (only on parent handlers..)
|
||||
self._parent = parent_handler
|
||||
self._request = parent_handler._request
|
||||
self._path = path
|
||||
self._params = params
|
||||
self._args = list(args)
|
||||
self._parent_item = parent_item
|
||||
self._user = user
|
||||
self._odata = parent_handler._odata # Ref to parent OData
|
||||
self._headers = parent_handler._headers # "link" headers
|
||||
|
||||
def _check_is_custom_method(self, check: str, parent: models.Model, arg: typing.Any = None) -> typing.Any:
|
||||
"""
|
||||
checks curron methods
|
||||
:param check: Method to check
|
||||
:param parent: Parent Model Element
|
||||
:param arg: argument to pass to custom method
|
||||
"""
|
||||
for to_check in self.CUSTOM_METHODS:
|
||||
camel_case_name, snake_case_name = camel_and_snake_case_from(to_check)
|
||||
if check in (camel_case_name, snake_case_name):
|
||||
operation = getattr(self, snake_case_name, None) or getattr(self, camel_case_name, None)
|
||||
if operation:
|
||||
if not arg:
|
||||
return operation(parent)
|
||||
return operation(parent, arg)
|
||||
|
||||
return consts.rest.NOT_FOUND
|
||||
|
||||
# pylint: disable=too-many-branches,too-many-return-statements
|
||||
def get(self) -> typing.Any:
|
||||
"""
|
||||
Processes GET method for a detail Handler
|
||||
"""
|
||||
# Process args
|
||||
logger.debug('Detail args for GET: %s', self._args)
|
||||
|
||||
parent: models.Model = self._parent_item
|
||||
|
||||
# if has custom methods, look for if this request matches any of them
|
||||
r = self._check_is_custom_method(self._args[0], parent)
|
||||
if r is not consts.rest.NOT_FOUND:
|
||||
return r
|
||||
|
||||
match self._args:
|
||||
case []: # same as overview
|
||||
return self.get_items(parent)
|
||||
case [consts.rest.OVERVIEW]:
|
||||
return self.get_items(parent)
|
||||
case [consts.rest.OVERVIEW, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid overview request') from None
|
||||
case [consts.rest.TYPES]:
|
||||
types = self.enum_types(parent, None)
|
||||
logger.debug('Types: %s', types)
|
||||
return [i.as_dict() for i in types]
|
||||
case [consts.rest.TYPES, for_type]:
|
||||
return [i.as_dict() for i in self.enum_types(parent, for_type)]
|
||||
case [consts.rest.TYPES, for_type, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid types request') from None
|
||||
case [consts.rest.TABLEINFO]:
|
||||
return self.get_table(parent).as_dict()
|
||||
case [consts.rest.TABLEINFO, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid table info request') from None
|
||||
case [consts.rest.GUI]:
|
||||
return sorted(self.get_processed_gui(parent, ''), key=lambda f: f.gui.order)
|
||||
case [consts.rest.GUI, for_type]:
|
||||
return sorted(self.get_processed_gui(parent, for_type), key=lambda f: f.gui.order)
|
||||
case [consts.rest.GUI, for_type, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid GUI request') from None
|
||||
case [item_id, consts.rest.LOG]:
|
||||
return self.get_logs(parent, item_id)
|
||||
case [consts.rest.LOG, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid log request') from None
|
||||
case [consts.rest.POSITION, item_uuid]:
|
||||
return self.get_item_position(parent, item_uuid)
|
||||
case [one_arg]:
|
||||
return self.get_item(parent, process_uuid(one_arg))
|
||||
case _:
|
||||
# Maybe a custom method?
|
||||
r = self._check_is_custom_method(self._args[1], parent, self._args[0])
|
||||
if r is not None:
|
||||
return r
|
||||
|
||||
# Not understood, fallback, maybe the derived class can understand it
|
||||
return self.fallback_get()
|
||||
|
||||
def put(self) -> typing.Any:
|
||||
"""
|
||||
Process the "PUT" operation, making the correspondent checks.
|
||||
Evaluates if it is a new element or a "modify" operation (based on if it has parameter),
|
||||
and invokes "save_item" with parent & item (that can be None for a new Item)
|
||||
"""
|
||||
logger.debug('Detail args for PUT: %s, %s', self._args, self._params)
|
||||
|
||||
parent: models.Model = self._parent_item
|
||||
|
||||
# if has custom methods, look for if this request matches any of them
|
||||
if len(self._args) > 1:
|
||||
r = self._check_is_custom_method(self._args[1], parent)
|
||||
if r is not consts.rest.NOT_FOUND:
|
||||
return r
|
||||
|
||||
# Create new item unless 1 param received (the id of the item to modify)
|
||||
item = None
|
||||
if len(self._args) == 1:
|
||||
item = self._args[0]
|
||||
elif len(self._args) > 1: # PUT expects 0 or 1 parameters. 0 == NEW, 1 = EDIT
|
||||
raise exceptions.rest.RequestError('Invalid PUT request') from None
|
||||
|
||||
logger.debug('Invoking proper saving detail item %s', item)
|
||||
return rest_result(self.save_item(parent, item))
|
||||
|
||||
def post(self) -> typing.Any:
|
||||
"""
|
||||
Process the "POST" operation
|
||||
Post can be used for, for example, testing.
|
||||
Right now is an invalid method for Detail elements
|
||||
"""
|
||||
raise exceptions.rest.RequestError('This method does not accepts POST') from None
|
||||
|
||||
def delete(self) -> typing.Any:
|
||||
"""
|
||||
Process the "DELETE" operation, making the correspondent checks.
|
||||
Extracts the item id and invokes delete_item with parent item and item id (uuid)
|
||||
"""
|
||||
logger.debug('Detail args for DELETE: %s', self._args)
|
||||
|
||||
parent = self._parent_item
|
||||
|
||||
if len(self._args) != 1:
|
||||
raise exceptions.rest.RequestError('Invalid DELETE request') from None
|
||||
|
||||
self.delete_item(parent, self._args[0])
|
||||
|
||||
return consts.OK
|
||||
|
||||
def fallback_get(self) -> typing.Any:
|
||||
"""
|
||||
Invoked if default get can't process request.
|
||||
Here derived classes can process "non default" (and so, not understood) GET constructions
|
||||
"""
|
||||
raise exceptions.rest.RequestError('Invalid GET request') from None
|
||||
|
||||
# Override this to provide functionality
|
||||
# Default (as sample) get_items
|
||||
@abc.abstractmethod
|
||||
def get_items(self, parent: models.Model) -> types.rest.ItemsResult[types.rest.T_Item]:
|
||||
"""
|
||||
This MUST be overridden by derived classes
|
||||
Excepts to return a list of dictionaries or a single dictionary, depending on "item" param
|
||||
If "item" param is None, ALL items are expected to be returned as a list of dictionaries
|
||||
If "Item" param has an id (normally an uuid), one item is expected to be returned as dictionary
|
||||
"""
|
||||
# if item is None: # Returns ALL detail items
|
||||
# return []
|
||||
# return {} # Returns one item
|
||||
raise NotImplementedError(f'Must provide an get_items method for {self.__class__} class')
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_item(self, parent: models.Model, item: str) -> types.rest.T_Item:
|
||||
"""
|
||||
Utility method to get a single item by uuid
|
||||
:param parent: Parent model
|
||||
:param item: Item uuid
|
||||
:return: Item as dictionary
|
||||
"""
|
||||
raise NotImplementedError(f'Must provide an get_item method for {self.__class__} class')
|
||||
|
||||
# Default save
|
||||
def save_item(self, parent: models.Model, item: typing.Optional[str]) -> types.rest.T_Item:
|
||||
"""
|
||||
Invoked for a valid "put" operation
|
||||
If this method is not overridden, the detail class will not have "Save/modify" operations.
|
||||
Parameters (probably object fields) must be retrieved from "_params" member variable
|
||||
:param parent: Parent of this detail (parent DB Object)
|
||||
:param item: Item id (uuid)
|
||||
:return: Normally "success" is expected, but can throw any "exception"
|
||||
"""
|
||||
logger.debug('Default save_item handler caller for %s', self._path)
|
||||
raise exceptions.rest.RequestError('Invalid PUT request') from None
|
||||
|
||||
# Default delete
|
||||
def delete_item(self, parent: models.Model, item: str) -> None:
|
||||
"""
|
||||
Invoked for a valid "delete" operation.
|
||||
If this method is not overriden, the detail class will not have "delete" operation.
|
||||
:param parent: Parent of this detail (parent DB Object)
|
||||
:param item: Item id (uuid)
|
||||
:return: Normally "success" is expected, but can throw any "exception"
|
||||
"""
|
||||
raise exceptions.rest.InvalidMethodError('Object does not support delete')
|
||||
|
||||
def get_table(self, parent: models.Model) -> types.rest.TableInfo:
|
||||
"""
|
||||
Returns the table info for this detail, that is the title, fields and row style
|
||||
:param parent: Parent object
|
||||
:return: TableInfo object with title, fields and row style
|
||||
"""
|
||||
return types.rest.TableInfo.null()
|
||||
|
||||
def get_gui(self, parent: models.Model, for_type: str) -> list[types.ui.GuiElement]:
|
||||
"""
|
||||
Gets the gui that is needed in order to "edit/add" new items on this detail
|
||||
If not overriden, means that the detail has no edit/new Gui
|
||||
|
||||
Args:
|
||||
parent (models.Model): Parent object
|
||||
for_type (str): Type of object needing gui
|
||||
|
||||
Return:
|
||||
list[types.ui.GuiElement]: A list of gui fields
|
||||
"""
|
||||
# raise RequestError('Gui not provided for this type of object')
|
||||
return []
|
||||
|
||||
def get_processed_gui(self, parent: models.Model, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return sorted(self.get_gui(parent, for_type), key=lambda f: f.gui.order)
|
||||
|
||||
def enum_types(
|
||||
self, parent: models.Model, for_type: typing.Optional[str]
|
||||
) -> collections.abc.Iterable[types.rest.TypeInfo]:
|
||||
"""
|
||||
The default is that detail element will not have any types (they are "homogeneous")
|
||||
but we provided this method, that can be overridden, in case one detail needs it
|
||||
(for example, on services)
|
||||
|
||||
Args:
|
||||
parent (models.Model): Parent object
|
||||
for_type (typing.Optional[str]): Request argument in fact
|
||||
|
||||
Return:
|
||||
collections.abc.Iterable[types.rest.TypeInfoDict]: A list of dictionaries describing type/types
|
||||
"""
|
||||
return [] # Default is that details do not have types
|
||||
|
||||
def get_logs(self, parent: models.Model, item: str) -> list[typing.Any]:
|
||||
"""
|
||||
If the detail has any log associated with it items, provide it overriding this method
|
||||
|
||||
Args:
|
||||
parent: Parent model
|
||||
item: Item id (uuid)
|
||||
|
||||
Returns:
|
||||
A list of log elements (normally got using "uds.core.util.log.get_logs" method)
|
||||
"""
|
||||
raise exceptions.rest.InvalidMethodError('Object does not support logs')
|
||||
|
||||
def calc_item_position(self, item_uuid: str, qs: 'QuerySet[T]') -> int:
|
||||
"""
|
||||
Helper method to get the position of an item in a queryset
|
||||
|
||||
Args:
|
||||
item_uuid (str): UUID of the item to find
|
||||
qs (QuerySet[T]): Queryset to search into
|
||||
|
||||
Returns:
|
||||
int: Position of the item in the default ordering, -1 if not found
|
||||
"""
|
||||
# Find item in qs, may be none, then return -1
|
||||
obj = qs.filter(uuid__iexact=item_uuid).first()
|
||||
if obj:
|
||||
return model_utils.get_position_in_queryset(obj, qs)
|
||||
return -1
|
||||
|
||||
|
||||
def get_item_position(self, parent: models.Model, item_uuid: str) -> int:
|
||||
"""
|
||||
Tries to get the position of an item in the default ordering of the detail items
|
||||
|
||||
Args:
|
||||
item_uuid (str): UUID of the item to find
|
||||
Returns:
|
||||
int: Position of the item in the default ordering, -1 if not found
|
||||
|
||||
Note:
|
||||
Override this method if the detail can provide item position
|
||||
"""
|
||||
return -1
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
|
||||
"""
|
||||
Note: This method returns ALL POSSIBLE TYPES for the specific model, not just those
|
||||
related to the father. Is used for api composition.
|
||||
enum_types, hear, is the one to filter types by parent, etc..
|
||||
"""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
"""
|
||||
Default implementation does not have any component types. (for Api specification purposes)
|
||||
"""
|
||||
# If no get_items, has no components (if custom components is needed, override this classmethod)
|
||||
return api_utils.get_component_from_type(cls)
|
||||
|
||||
@classmethod
|
||||
def api_paths(
|
||||
cls: type[typing.Self], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
from .api_helpers import api_paths
|
||||
|
||||
return api_paths(cls, path, tags=tags, security=security)
|
||||
@@ -1,205 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-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
|
||||
"""
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core import types
|
||||
from uds.core.util import api as api_utils
|
||||
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.REST.model.master import DetailHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
|
||||
def api_paths(
|
||||
cls: type['DetailHandler[types.rest.T_Item]'], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
|
||||
name = cls.REST_API_INFO.name if cls.REST_API_INFO.name else path.split('/')[-1].capitalize()
|
||||
get_tags = tags
|
||||
put_tags = tags # + ['Create', 'Modify']
|
||||
# post_tags = tags + ['Create']
|
||||
delete_tags = tags # + ['Delete']
|
||||
|
||||
base_type = next(iter(api_utils.get_generic_types(cls)), None)
|
||||
if base_type is None:
|
||||
logger.error('Base type not detected: %s', cls)
|
||||
return {} # Skip
|
||||
else:
|
||||
base_type_name = base_type.__name__
|
||||
# TODO: Append "custom" methods
|
||||
api_desc = {
|
||||
path: types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get all {name} items',
|
||||
description=f'Retrieve a list of all {name} items',
|
||||
parameters=api_utils.gen_odata_parameters(),
|
||||
responses=api_utils.gen_response(base_type_name, single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
),
|
||||
put=types.rest.api.Operation(
|
||||
summary=f'Creates a new {name} items',
|
||||
description=f'Update an existing {name} item',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=put_tags,
|
||||
security=security,
|
||||
),
|
||||
),
|
||||
f'{path}/{{uuid}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get {name} item by UUID',
|
||||
description=f'Retrieve a {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
),
|
||||
put=types.rest.api.Operation(
|
||||
summary=f'Update {name} item by UUID',
|
||||
description=f'Update an existing {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=put_tags,
|
||||
security=security,
|
||||
),
|
||||
delete=types.rest.api.Operation(
|
||||
summary=f'Delete {name} item by UUID',
|
||||
description=f'Delete a {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=delete_tags,
|
||||
security=security,
|
||||
),
|
||||
),
|
||||
f'{path}/{consts.rest.OVERVIEW}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get overview of {name} items',
|
||||
description=f'Retrieve an overview of {name} items',
|
||||
parameters=api_utils.gen_odata_parameters(),
|
||||
responses=api_utils.gen_response(base_type_name, single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TABLEINFO}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get table info of {name} items',
|
||||
description=f'Retrieve table info of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('TableInfo'),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
if cls.REST_API_INFO.typed.is_single_type():
|
||||
api_desc[f'{path}/{consts.rest.GUI}'] = types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get GUI representation of {name} items',
|
||||
description=f'Retrieve the GUI representation of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('GuiElement', single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
)
|
||||
|
||||
if cls.REST_API_INFO.typed.supports_multiple_types():
|
||||
api_desc.update(
|
||||
{
|
||||
f'{path}/{consts.rest.GUI}/{{type}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get GUI representation of {name} type',
|
||||
description=f'Retrieve a {name} GUI representation by type',
|
||||
parameters=[
|
||||
types.rest.api.Parameter(
|
||||
name='type',
|
||||
in_='path',
|
||||
required=True,
|
||||
description=f'The type of the {name} GUI representation',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
)
|
||||
],
|
||||
responses=api_utils.gen_response('GuiElement', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TYPES}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get types of {name} items',
|
||||
description=f'Retrieve types of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('TypeInfo', single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TYPES}/{{type}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get {name} item by type',
|
||||
description=f'Retrieve a {name} item by type',
|
||||
parameters=[
|
||||
types.rest.api.Parameter(
|
||||
name='type',
|
||||
in_='path',
|
||||
required=True,
|
||||
description='The type of the item',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
)
|
||||
],
|
||||
responses=api_utils.gen_response('TypeInfo', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return api_desc
|
||||
@@ -1,519 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-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
|
||||
"""
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
import logging
|
||||
import typing
|
||||
import abc
|
||||
import collections.abc
|
||||
|
||||
from django.db import IntegrityError, models
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core import exceptions
|
||||
from uds.core import types
|
||||
from uds.core.module import Module
|
||||
from uds.core.util import log, permissions, model as model_utils, api as api_utils
|
||||
from uds.models import ManagedObjectModel, Tag, TaggingMixin
|
||||
|
||||
from uds.REST.model.base import BaseModelHandler
|
||||
from uds.REST.utils import camel_and_snake_case_from
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.REST.model.detail import DetailHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
|
||||
class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
|
||||
"""
|
||||
Basic Handler for a model
|
||||
Basically we will need same operations for all models, so we can
|
||||
take advantage of this fact to not repeat same code again and again...
|
||||
|
||||
Urls treated are:
|
||||
[path] --> Returns all elements for this path (including INSTANCE variables if it has it). (example: .../providers)
|
||||
[path]/overview --> Returns all elements for this path, not including INSTANCE variables. (example: .../providers/overview)
|
||||
[path]/ID --> Returns an exact element for this path. (example: .../providers/4)
|
||||
[path/ID/DETAIL --> Delegates to Detail, if it has details. (example: .../providers/4/services/overview, .../providers/5/services/9/gui, ....
|
||||
|
||||
Note: Instance variables are the variables declared and serialized by modules.
|
||||
The only detail that has types within is "Service", child of "Provider"
|
||||
"""
|
||||
|
||||
# Authentication related
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
# Which model does this manage, must be a django model ofc
|
||||
MODEL: 'typing.ClassVar[type[models.Model]]'
|
||||
# If the model is filtered (for overviews)
|
||||
FILTER: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
|
||||
# Same, but for exclude
|
||||
EXCLUDE: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
|
||||
|
||||
# If this model respond to "custom" methods, we will declare them here
|
||||
# This is an array of tuples of two items, where first is method and second inticates if method needs parent id (normal behavior is it needs it)
|
||||
# For example ('services', True) -- > .../id_parent/services
|
||||
# ('services', False) --> ..../services
|
||||
CUSTOM_METHODS: typing.ClassVar[list[types.rest.ModelCustomMethod]] = []
|
||||
|
||||
# If this model has details, which ones
|
||||
# Dictionary containing detail routing
|
||||
DETAIL: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler[typing.Any]']]]] = None
|
||||
# Fields that are going to be saved directly
|
||||
# * If a field is in the form "field:default" and field is not present in the request, default will be used
|
||||
# * If the "default" is the string "None", then the default will be None
|
||||
# * If the "default" is _ (underscore), then the field will be ignored (not saved) if not present in the request
|
||||
# Note that these fields has to be present in the model, and they can be "edited" in the pre_save method
|
||||
FIELDS_TO_SAVE: typing.ClassVar[list[str]] = []
|
||||
# Put removable fields before updating
|
||||
EXCLUDED_FIELDS: typing.ClassVar[list[str]] = []
|
||||
# Table info needed fields and title
|
||||
|
||||
TABLE: typing.ClassVar[types.rest.TableInfo] = types.rest.TableInfo.null()
|
||||
|
||||
# This methods must be override, depending on what is provided
|
||||
|
||||
# types related
|
||||
# def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type['Module']]: # override this
|
||||
"""
|
||||
Must be overriden by desdencents if they support types
|
||||
Excpetcs the list of types that the handler supports
|
||||
"""
|
||||
return []
|
||||
|
||||
def enum_types(self) -> typing.Generator[types.rest.TypeInfo, None, None]:
|
||||
for type_ in self.possible_types():
|
||||
yield type(self).as_typeinfo(type_)
|
||||
|
||||
def get_type(self, type_: str) -> types.rest.TypeInfo:
|
||||
for v in self.enum_types():
|
||||
if v.type == type_:
|
||||
return v
|
||||
|
||||
raise exceptions.rest.NotFound('type not found')
|
||||
|
||||
# log related
|
||||
def get_logs(self, item: models.Model) -> list[dict[typing.Any, typing.Any]]:
|
||||
self.check_access(item, types.permissions.PermissionType.READ)
|
||||
try:
|
||||
return log.get_logs(item)
|
||||
except Exception as e:
|
||||
logger.warning('Exception getting logs for %s: %s', item, e)
|
||||
return []
|
||||
|
||||
# gui related
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return []
|
||||
# raise self.invalidRequestException()
|
||||
|
||||
def get_processed_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return sorted(self.get_gui(for_type), key=lambda f: f.gui.order)
|
||||
|
||||
# Delete related, checks if the item can be deleted
|
||||
# If it can't be so, raises an exception
|
||||
def validate_delete(self, item: models.Model) -> None:
|
||||
pass
|
||||
|
||||
# Save related, checks if the item can be saved
|
||||
# If it can't be saved, raises an exception
|
||||
def validate_save(self, item: models.Model) -> None:
|
||||
pass
|
||||
|
||||
# Invoked to possibily fix fields (or add new one, or check
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
pass
|
||||
|
||||
# Invoked right after saved an item (no matter if new or edition)
|
||||
def post_save(self, item: models.Model) -> None:
|
||||
pass
|
||||
|
||||
# End overridable
|
||||
|
||||
# Helper to process detail
|
||||
# Details can be managed (writen) by any user that has MANAGEMENT permission over parent
|
||||
def process_detail(self) -> typing.Any:
|
||||
logger.debug('Processing detail %s for with params %s', self._path, self._params)
|
||||
try:
|
||||
item: models.Model = self.MODEL.objects.get(uuid__iexact=self._args[0])
|
||||
# If we do not have access to parent to, at least, read...
|
||||
|
||||
if self._operation in ('put', 'post', 'delete'):
|
||||
required_permission = types.permissions.PermissionType.MANAGEMENT
|
||||
else:
|
||||
required_permission = types.permissions.PermissionType.READ
|
||||
|
||||
if permissions.has_access(self._user, item, required_permission) is False:
|
||||
logger.debug(
|
||||
'Permission for user %s does not comply with %s',
|
||||
self._user,
|
||||
required_permission,
|
||||
)
|
||||
raise exceptions.rest.AccessDenied()
|
||||
|
||||
if not self.DETAIL:
|
||||
raise exceptions.rest.NotFound('Detail not found')
|
||||
|
||||
# pylint: disable=unsubscriptable-object
|
||||
handler_type = self.DETAIL[self._args[1]]
|
||||
args = list(self._args[2:])
|
||||
path = self._path + '/' + '/'.join(args[:2])
|
||||
detail_handler = handler_type(self, path, self._params, *args, parent_item=item, user=self._user)
|
||||
method = getattr(detail_handler, self._operation)
|
||||
|
||||
return method()
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise exceptions.rest.NotFound('Item not found on model {self.MODEL.__name__}')
|
||||
except (KeyError, AttributeError) as e:
|
||||
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from e
|
||||
except exceptions.rest.HandlerError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Exception processing detail: %s', e)
|
||||
raise exceptions.rest.RequestError(f'Error processing detail: {e}') from e
|
||||
|
||||
# Data related
|
||||
def get_item(self, item: models.Model) -> types.rest.T_Item:
|
||||
"""
|
||||
Must be overriden by descendants.
|
||||
Expects the return of an item as a dictionary
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_item_summary(self, item: models.Model) -> types.rest.T_Item:
|
||||
"""
|
||||
Invoked when request is an "overview"
|
||||
default behavior is return item_as_dict
|
||||
"""
|
||||
return self.get_item(item)
|
||||
|
||||
def filter_model_queryset(self, qs: QuerySet[T]|None = None) -> QuerySet[T]:
|
||||
qs = typing.cast('QuerySet[T]', self.MODEL.objects.all()) if qs is None else qs
|
||||
|
||||
if self.FILTER is not None:
|
||||
qs = qs.filter(**self.FILTER)
|
||||
if self.EXCLUDE is not None:
|
||||
qs = qs.exclude(**self.EXCLUDE)
|
||||
|
||||
return qs
|
||||
|
||||
def get_item_position(self, item_uuid: str, query: QuerySet[T] | None = None) -> int:
|
||||
qs = self.filter_model_queryset(query)
|
||||
|
||||
# Find item in qs, may be none, then return -1
|
||||
obj = qs.filter(uuid__iexact=item_uuid).first()
|
||||
if obj:
|
||||
return model_utils.get_position_in_queryset(obj, qs)
|
||||
return -1
|
||||
|
||||
|
||||
def get_items(
|
||||
self, *, sumarize: bool = False, query: QuerySet[T] | None = None
|
||||
) -> typing.Generator[types.rest.T_Item, None, None]:
|
||||
"""
|
||||
Get items from the model.
|
||||
Args:
|
||||
sumarize: If True, return a summary of the items.
|
||||
query: Optional queryset to filter the items. Used to optimize the process for some models
|
||||
(such as ServicePools)
|
||||
|
||||
"""
|
||||
|
||||
# Basic model filter
|
||||
qs = self.filter_model_queryset(query)
|
||||
|
||||
# Custom filtering from params (odata, etc)
|
||||
qs = self.odata_filter(qs)
|
||||
|
||||
for item in qs:
|
||||
try:
|
||||
# Note: Due to this, the response may not have the required elements, but a subset will be returned
|
||||
if (
|
||||
permissions.has_access(
|
||||
self._user,
|
||||
item,
|
||||
types.permissions.PermissionType.READ,
|
||||
)
|
||||
is False
|
||||
):
|
||||
continue
|
||||
yield self.get_item_summary(item) if sumarize else self.get_item(item)
|
||||
except Exception as e: # maybe an exception is thrown to skip an item
|
||||
logger.debug('Got exception processing item from model: %s', e)
|
||||
# logger.exception('Exception getting item from {0}'.format(self.model))
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
logger.debug('method GET for %s, %s', self.__class__.__name__, self._args)
|
||||
number_of_args = len(self._args)
|
||||
|
||||
# if has custom methods, look for if this request matches any of them
|
||||
for cm in self.CUSTOM_METHODS:
|
||||
# Convert to snake case
|
||||
camel_case_name, snake_case_name = camel_and_snake_case_from(cm.name)
|
||||
if number_of_args > 1 and cm.needs_parent: # Method needs parent (existing item)
|
||||
if self._args[1] in (camel_case_name, snake_case_name):
|
||||
item = None
|
||||
# Check if operation method exists
|
||||
operation = getattr(self, snake_case_name, None) or getattr(self, camel_case_name, None)
|
||||
try:
|
||||
if not operation:
|
||||
raise Exception() # Operation not found
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0])
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise exceptions.rest.NotFound('Item not found') from None
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Invalid custom method exception %s/%s/%s: %s',
|
||||
self.__class__.__name__,
|
||||
self._args,
|
||||
self._params,
|
||||
e,
|
||||
)
|
||||
raise exceptions.rest.ResponseError(
|
||||
f'Error processing custom method: {self.__class__.__name__}/{self._args}'
|
||||
) from e
|
||||
|
||||
return operation(item)
|
||||
|
||||
elif self._args[0] in (snake_case_name, snake_case_name):
|
||||
operation = getattr(self, snake_case_name) or getattr(self, snake_case_name)
|
||||
if not operation:
|
||||
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
|
||||
|
||||
return operation()
|
||||
|
||||
match self._args:
|
||||
case []: # Same as overview, but with all data
|
||||
return [i.as_dict() for i in self.get_items(sumarize=False)]
|
||||
case [consts.rest.OVERVIEW]:
|
||||
return [i.as_dict() for i in self.get_items()]
|
||||
case [consts.rest.OVERVIEW, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid overview request') from None
|
||||
case [consts.rest.TABLEINFO]:
|
||||
return self.TABLE.as_dict()
|
||||
case [consts.rest.TABLEINFO, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid table info request') from None
|
||||
case [consts.rest.TYPES]:
|
||||
return [i.as_dict() for i in self.enum_types()]
|
||||
case [consts.rest.TYPES, for_type]:
|
||||
return self.get_type(for_type).as_dict()
|
||||
case [consts.rest.TYPES, for_type, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid type request') from None
|
||||
case [consts.rest.GUI]:
|
||||
return self.get_processed_gui('')
|
||||
case [consts.rest.GUI, for_type]:
|
||||
return self.get_processed_gui(for_type)
|
||||
case [consts.rest.GUI, for_type, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid GUI request') from None
|
||||
case [consts.rest.POSITION, item_uuid]:
|
||||
return self.get_item_position(item_uuid)
|
||||
case _: # Maybe an item or a detail
|
||||
if number_of_args == 1:
|
||||
try:
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
|
||||
self.check_access(item, types.permissions.PermissionType.READ)
|
||||
return self.get_item(item).as_dict()
|
||||
except Exception as e:
|
||||
logger.exception('Got Exception looking for item')
|
||||
raise exceptions.rest.NotFound('Item not found') from e
|
||||
elif number_of_args == 2:
|
||||
if self._args[1] == consts.rest.LOG:
|
||||
try:
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
|
||||
return self.get_logs(item)
|
||||
except Exception as e:
|
||||
raise exceptions.rest.NotFound('Item not found') from e
|
||||
|
||||
if self.DETAIL is not None:
|
||||
return self.process_detail()
|
||||
|
||||
raise exceptions.rest.RequestError('Invalid request') from None
|
||||
|
||||
def post(self) -> typing.Any:
|
||||
"""
|
||||
Processes a POST request
|
||||
"""
|
||||
# right now
|
||||
logger.debug('method POST for %s, %s', self.__class__.__name__, self._args)
|
||||
if len(self._args) == 2:
|
||||
if self._args[0] == 'test':
|
||||
return self.test(self._args[1])
|
||||
|
||||
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
|
||||
|
||||
def put(self) -> typing.Any:
|
||||
"""
|
||||
Processes a PUT request
|
||||
"""
|
||||
logger.debug('method PUT for %s, %s', self.__class__.__name__, self._args)
|
||||
|
||||
# Append request to _params, may be needed by some classes
|
||||
# I.e. to get the user IP, server name, etc..
|
||||
self._params['_request'] = self._request
|
||||
|
||||
delete_on_error = False
|
||||
|
||||
if len(self._args) > 1: # Detail (1 arg means ID, more means detail/ID)?
|
||||
return self.process_detail()
|
||||
|
||||
# Here, self.model() indicates an "django model object with default params"
|
||||
self.check_access(
|
||||
self.MODEL(), types.permissions.PermissionType.ALL, root=True
|
||||
) # Must have write permissions to create, modify, etc..
|
||||
|
||||
try:
|
||||
# Extract fields
|
||||
args = self.fields_from_params(self.FIELDS_TO_SAVE)
|
||||
logger.debug('Args: %s', args)
|
||||
self.pre_save(args)
|
||||
# If tags is in save fields, treat it "specially"
|
||||
if 'tags' in self.FIELDS_TO_SAVE:
|
||||
tags = args['tags']
|
||||
del args['tags']
|
||||
else:
|
||||
tags = None
|
||||
|
||||
delete_on_error = False
|
||||
item: models.Model
|
||||
if not self._args: # create new?
|
||||
item = self.MODEL.objects.create(**args)
|
||||
delete_on_error = True
|
||||
else: # Must have 1 arg
|
||||
# We have to take care with this case, update will efectively update records on db
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
|
||||
for v in self.EXCLUDED_FIELDS:
|
||||
if v in args:
|
||||
del args[v]
|
||||
# Upadte fields from args
|
||||
for k, v in args.items():
|
||||
setattr(item, k, v)
|
||||
|
||||
# Now if tags, update them
|
||||
if isinstance(item, TaggingMixin):
|
||||
if tags:
|
||||
logger.debug('Updating tags: %s', tags)
|
||||
item.tags.set([Tag.objects.get_or_create(tag=val)[0] for val in tags if val != ''])
|
||||
elif isinstance(tags, list): # Present, but list is empty (will be proccesed on "if" else)
|
||||
item.tags.clear()
|
||||
|
||||
if not delete_on_error:
|
||||
self.validate_save(
|
||||
item
|
||||
) # Will raise an exception if item can't be saved (only for modify operations..)
|
||||
|
||||
# Store associated object if requested (data_type)
|
||||
try:
|
||||
if isinstance(item, ManagedObjectModel):
|
||||
data_type: typing.Optional[str] = self._params.get('data_type', self._params.get('type'))
|
||||
if data_type:
|
||||
item.data_type = data_type
|
||||
# TODO: Currently support parameters outside "instance". Will be removed after tests
|
||||
item.data = item.get_instance(
|
||||
self._params['instance'] if 'instance' in self._params else self._params
|
||||
).serialize()
|
||||
|
||||
item.save()
|
||||
|
||||
res = self.get_item(item)
|
||||
except Exception:
|
||||
logger.exception('Exception on put')
|
||||
if delete_on_error:
|
||||
item.delete()
|
||||
raise
|
||||
|
||||
self.post_save(item)
|
||||
|
||||
return res.as_dict()
|
||||
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise exceptions.rest.NotFound('Item not found') from None
|
||||
except IntegrityError: # Duplicate key probably
|
||||
raise exceptions.rest.RequestError('Element already exists (duplicate key error)') from None
|
||||
except (exceptions.rest.SaveException, exceptions.ui.ValidationError) as e:
|
||||
raise exceptions.rest.RequestError(str(e)) from e
|
||||
except (exceptions.rest.RequestError, exceptions.rest.ResponseError):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception('Exception on put')
|
||||
raise exceptions.rest.RequestError('incorrect invocation to PUT') from e
|
||||
|
||||
def delete(self) -> typing.Any:
|
||||
"""
|
||||
Processes a DELETE request
|
||||
"""
|
||||
logger.debug('method DELETE for %s, %s', self.__class__.__name__, self._args)
|
||||
if len(self._args) > 1:
|
||||
return self.process_detail()
|
||||
|
||||
if len(self._args) != 1:
|
||||
raise exceptions.rest.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:
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
|
||||
self.validate_delete(item)
|
||||
self.delete_item(item)
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise exceptions.rest.NotFound('Element do not exists') from None
|
||||
|
||||
return consts.OK
|
||||
|
||||
def delete_item(self, item: models.Model) -> None:
|
||||
"""
|
||||
Basic, overridable method for deleting an item
|
||||
"""
|
||||
item.delete()
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
return api_utils.get_component_from_type(cls)
|
||||
|
||||
@classmethod
|
||||
def api_paths(
|
||||
cls: type[typing.Self], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
from .api_helpers import api_paths
|
||||
|
||||
return api_paths(cls, path, tags=tags, security=security)
|
||||
@@ -1,205 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-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
|
||||
"""
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core import types
|
||||
from uds.core.util import api as api_utils
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.REST.model.master import ModelHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
|
||||
def api_paths(
|
||||
cls: type['ModelHandler[types.rest.T_Item]'], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
|
||||
name = cls.REST_API_INFO.name if cls.REST_API_INFO.name else cls.MODEL.__name__
|
||||
get_tags = tags
|
||||
put_tags = tags # + ['Create', 'Modify']
|
||||
# post_tags = tags + ['Create']
|
||||
delete_tags = tags # + ['Delete']
|
||||
|
||||
base_type = next(iter(api_utils.get_generic_types(cls)), None)
|
||||
if base_type is None:
|
||||
logger.error('Base type not detected: %s', cls)
|
||||
return {} # Skip
|
||||
else:
|
||||
base_type_name = base_type.__name__
|
||||
|
||||
api_desc = {
|
||||
path: types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get all {name} items',
|
||||
description=f'Retrieve a list of all {name} items',
|
||||
parameters=api_utils.gen_odata_parameters(),
|
||||
responses=api_utils.gen_response(base_type_name, single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
),
|
||||
put=types.rest.api.Operation(
|
||||
summary=f'Creates a new {name} item',
|
||||
description=f'Creates a new, nonexisting {name} item',
|
||||
parameters=[],
|
||||
requestBody=api_utils.gen_request_body(base_type_name, create=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=put_tags,
|
||||
security=security,
|
||||
),
|
||||
),
|
||||
f'{path}/{{uuid}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get {name} item by UUID',
|
||||
description=f'Retrieve a {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
),
|
||||
put=types.rest.api.Operation(
|
||||
summary=f'Update {name} item by UUID',
|
||||
description=f'Update an existing {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=False),
|
||||
requestBody=api_utils.gen_request_body(base_type_name, create=False),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=put_tags,
|
||||
security=security,
|
||||
),
|
||||
delete=types.rest.api.Operation(
|
||||
summary=f'Delete {name} item by UUID',
|
||||
description=f'Delete a {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=False),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=delete_tags,
|
||||
security=security,
|
||||
),
|
||||
),
|
||||
f'{path}/{consts.rest.OVERVIEW}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get overview of {name} items',
|
||||
description=f'Retrieve an overview of {name} items',
|
||||
parameters=api_utils.gen_odata_parameters(),
|
||||
responses=api_utils.gen_response(base_type_name, single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TABLEINFO}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get table info of {name} items',
|
||||
description=f'Retrieve table info of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('TableInfo', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
}
|
||||
if cls.REST_API_INFO.typed.is_single_type():
|
||||
api_desc[f'{path}/{consts.rest.GUI}'] = types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get GUI representation of {name} items',
|
||||
description=f'Retrieve the GUI representation of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('GuiElement', single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
)
|
||||
|
||||
if cls.REST_API_INFO.typed.supports_multiple_types():
|
||||
api_desc.update(
|
||||
{
|
||||
f'{path}/{consts.rest.GUI}/{{type}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get GUI representation of {name} type',
|
||||
description=f'Retrieve a {name} GUI representation by type',
|
||||
parameters=[
|
||||
types.rest.api.Parameter(
|
||||
name='type',
|
||||
in_='path',
|
||||
required=True,
|
||||
description=f'The type of the {name} GUI representation',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
)
|
||||
],
|
||||
responses=api_utils.gen_response('GuiElement', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TYPES}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get types of {name} items',
|
||||
description=f'Retrieve types of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('TypeInfo', single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TYPES}/{{type}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get {name} item by type',
|
||||
description=f'Retrieve a {name} item by type',
|
||||
parameters=[
|
||||
types.rest.api.Parameter(
|
||||
name='type',
|
||||
in_='path',
|
||||
required=True,
|
||||
description='The type of the item',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
)
|
||||
],
|
||||
responses=api_utils.gen_response('TypeInfo', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return api_desc
|
||||
@@ -1,248 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-2021 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 collections.abc
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.utils.functional import Promise as DjangoPromise
|
||||
|
||||
from uds.core import consts, types
|
||||
|
||||
from .utils import to_incremental_json
|
||||
|
||||
# from xml_marshaller import xml_marshaller
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
class ParametersException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ContentProcessor:
|
||||
"""
|
||||
Process contents (request/response) so Handlers can manage them
|
||||
"""
|
||||
|
||||
mime_type: typing.ClassVar[str] = ''
|
||||
extensions: typing.ClassVar[collections.abc.Iterable[str]] = []
|
||||
|
||||
_request: 'HttpRequest'
|
||||
_odata: 'types.rest.api.ODataParams|None' = None
|
||||
|
||||
def __init__(self, request: 'HttpRequest'):
|
||||
self._request = request
|
||||
self._odata = None
|
||||
|
||||
def set_odata(self, odata: 'types.rest.api.ODataParams') -> None:
|
||||
self._odata = odata
|
||||
|
||||
def process_get_parameters(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
returns parameters based on request method
|
||||
GET parameters are understood
|
||||
"""
|
||||
if self._request.method != 'GET':
|
||||
return {}
|
||||
|
||||
return {k: v[0] if len(v) == 1 else v for k, v in self._request.GET.lists()}
|
||||
|
||||
def process_parameters(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Returns the parameter from the request
|
||||
"""
|
||||
return {}
|
||||
|
||||
def get_response(self, obj: typing.Any) -> HttpResponse:
|
||||
"""
|
||||
Converts an obj to a response of specific type (json, XML, ...)
|
||||
This is done using "render" method of specific type
|
||||
"""
|
||||
return HttpResponse(content=self.render(obj), content_type=self.mime_type + "; charset=utf-8")
|
||||
|
||||
def render(self, obj: typing.Any) -> str:
|
||||
"""
|
||||
Renders an obj to the spefific type
|
||||
"""
|
||||
return str(obj)
|
||||
|
||||
def as_incremental(self, obj: typing.Any) -> collections.abc.Iterable[bytes]:
|
||||
"""
|
||||
Renders an obj to the specific type, but in an incremental way (if possible)
|
||||
"""
|
||||
yield self.render(obj).encode('utf8')
|
||||
|
||||
@staticmethod
|
||||
def process_for_render(
|
||||
obj: typing.Any,
|
||||
data_transformer: collections.abc.Callable[[dict[str, typing.Any]], dict[str, typing.Any]],
|
||||
) -> typing.Any:
|
||||
"""
|
||||
Helper for renderers. Alters some types so they can be serialized correctly (as we want them to be)
|
||||
"""
|
||||
match obj:
|
||||
case types.rest.BaseRestItem():
|
||||
return ContentProcessor.process_for_render(obj.as_dict(), data_transformer)
|
||||
# Dataclass
|
||||
case None | bool() | int() | float() | str():
|
||||
return obj
|
||||
case dict():
|
||||
return data_transformer(
|
||||
{
|
||||
k: ContentProcessor.process_for_render(v, data_transformer)
|
||||
for k, v in typing.cast(dict[str, typing.Any], obj).items()
|
||||
if not isinstance(v, types.rest.NotRequired) # Skip
|
||||
}
|
||||
)
|
||||
|
||||
case DjangoPromise():
|
||||
return str(obj) # This is for translations
|
||||
|
||||
case bytes():
|
||||
return obj.decode('utf-8')
|
||||
|
||||
case collections.abc.Iterable():
|
||||
return [
|
||||
ContentProcessor.process_for_render(v, data_transformer)
|
||||
for v in typing.cast(collections.abc.Iterable[typing.Any], obj)
|
||||
]
|
||||
|
||||
case datetime.datetime():
|
||||
return int(obj.timestamp())
|
||||
|
||||
case datetime.date():
|
||||
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
|
||||
|
||||
case _:
|
||||
# Any class with as_dict method shoud be processed
|
||||
if as_dict := getattr(obj, 'as_dict', None):
|
||||
try:
|
||||
obj = as_dict()
|
||||
return ContentProcessor.process_for_render(obj, data_transformer)
|
||||
except Exception as e:
|
||||
# Maybe the as_dict method is not implemented as we expect.. should not happen
|
||||
logger.warning('Obj has as_dict method but failed to call it: %s', e)
|
||||
# Will return obj as str in this case, or if it is a dataclass, can return as dict
|
||||
|
||||
if dataclasses.is_dataclass(obj):
|
||||
# If already has a "as_dict" method, use it, and if not, default
|
||||
obj = dataclasses.asdict(typing.cast(typing.Any, obj))
|
||||
return ContentProcessor.process_for_render(obj, data_transformer)
|
||||
|
||||
return str(obj)
|
||||
|
||||
|
||||
class MarshallerProcessor(ContentProcessor):
|
||||
"""
|
||||
If we have a simple marshaller for processing contents
|
||||
this class will allow us to set up a new one simply setting "marshaller"
|
||||
"""
|
||||
|
||||
marshaller: typing.ClassVar[typing.Any] = None
|
||||
|
||||
def process_parameters(self) -> dict[str, typing.Any]:
|
||||
try:
|
||||
length = int(self._request.META.get('CONTENT_LENGTH') or '0')
|
||||
if length == 0 or not self._request.body:
|
||||
return self.process_get_parameters()
|
||||
|
||||
# logger.debug('Body: >>{}<< {}'.format(self._request.body, len(self._request.body)))
|
||||
if length > consts.system.MAX_REQUEST_SIZE or length > len(self._request.body):
|
||||
raise ParametersException('Request size too big')
|
||||
|
||||
res = self.marshaller.loads(self._request.body.decode('utf8'))
|
||||
logger.debug('Unmarshalled content: %s', res)
|
||||
|
||||
if not isinstance(res, dict):
|
||||
raise ParametersException('Invalid content')
|
||||
|
||||
return typing.cast(dict[str, typing.Any], res)
|
||||
except Exception as e:
|
||||
logger.exception('parsing %s: %s', self.mime_type, self._request.body.decode('utf8'))
|
||||
raise ParametersException(str(e))
|
||||
|
||||
def render(self, obj: typing.Any) -> str:
|
||||
def none_transformer(dct: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
return dct
|
||||
|
||||
dct_filter = none_transformer if self._odata is None else self._odata.select_filter
|
||||
return self.marshaller.dumps(ContentProcessor.process_for_render(obj, dct_filter))
|
||||
|
||||
|
||||
# ---------------
|
||||
# Json Processor
|
||||
# ---------------
|
||||
class JsonProcessor(MarshallerProcessor):
|
||||
"""
|
||||
Provides JSON content processor
|
||||
"""
|
||||
|
||||
mime_type: typing.ClassVar[str] = 'application/json'
|
||||
extensions: typing.ClassVar[collections.abc.Iterable[str]] = ['json']
|
||||
marshaller: typing.ClassVar[typing.Any] = json
|
||||
|
||||
def as_incremental(self, obj: typing.Any) -> collections.abc.Iterable[bytes]:
|
||||
for i in to_incremental_json(obj):
|
||||
yield i.encode('utf8')
|
||||
|
||||
|
||||
# ---------------
|
||||
# XML Processor
|
||||
# ---------------
|
||||
# ===============================================================================
|
||||
# class XMLProcessor(MarshallerProcessor):
|
||||
# """
|
||||
# Provides XML content processor
|
||||
# """
|
||||
# mime_type = 'application/xml'
|
||||
# extensions = ['xml']
|
||||
# marshaller = xml_marshaller
|
||||
# ===============================================================================
|
||||
|
||||
|
||||
processors_list = (JsonProcessor,)
|
||||
default_processor: type[ContentProcessor] = JsonProcessor
|
||||
available_processors_mime_dict: dict[str, type[ContentProcessor]] = {
|
||||
cls.mime_type: cls for cls in processors_list
|
||||
}
|
||||
available_processors_ext_dict: dict[str, type[ContentProcessor]] = {}
|
||||
for cls in processors_list:
|
||||
for ext in cls.extensions:
|
||||
available_processors_ext_dict[ext] = cls
|
||||
@@ -1,75 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2024 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. 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 json
|
||||
import typing
|
||||
import re
|
||||
import collections.abc
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core.util.model import sql_stamp_seconds
|
||||
|
||||
|
||||
def rest_result(result: typing.Any, **kwargs: typing.Any) -> dict[str, typing.Any]:
|
||||
'''
|
||||
Returns a REST result
|
||||
'''
|
||||
# A common possible value in kwargs is "error"
|
||||
return {'result': result, 'stamp': sql_stamp_seconds(), 'version': consts.system.VERSION, 'build': consts.system.VERSION_STAMP,**kwargs}
|
||||
|
||||
|
||||
def camel_and_snake_case_from(text: str) -> tuple[str, str]:
|
||||
'''
|
||||
Returns a tuple with the camel case and snake case of a text
|
||||
first value is camel case, second is snake case
|
||||
'''
|
||||
snake_case_name = re.sub(r'(?<!^)(?=[A-Z])', '_', text).lower()
|
||||
# And snake case to camel case (first letter lower case, rest upper case)
|
||||
camel_case_name = ''.join(x.capitalize() for x in snake_case_name.split('_'))
|
||||
camel_case_name = camel_case_name[0].lower() + camel_case_name[1:]
|
||||
|
||||
return camel_case_name, snake_case_name
|
||||
|
||||
|
||||
def to_incremental_json(
|
||||
source: collections.abc.Generator[typing.Any, None, None]
|
||||
) -> typing.Generator[str, None, None]:
|
||||
'''
|
||||
Converts a generator to a json incremental string
|
||||
'''
|
||||
yield '['
|
||||
first = True
|
||||
for item in source:
|
||||
if first:
|
||||
first = False
|
||||
else:
|
||||
yield ','
|
||||
yield json.dumps(item)
|
||||
yield ']'
|
||||
@@ -1,122 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-2022 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
|
||||
"""
|
||||
# pyright: reportUnusedImport=false
|
||||
|
||||
# Make sure that all services are "available" at service startup
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db.backends.signals import connection_created
|
||||
|
||||
# from django.db.models.signals import post_migrate
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Set default ssl context unverified, as MOST servers that we will connect will be with self signed certificates...
|
||||
try:
|
||||
# _create_unverified_https_context = ssl._create_unverified_context
|
||||
# ssl._create_default_https_context = _create_unverified_https_context
|
||||
|
||||
# Capture warnnins to logg
|
||||
logging.captureWarnings(True)
|
||||
except AttributeError:
|
||||
# Legacy Python that doesn't verify HTTPS certificates by default
|
||||
pass
|
||||
|
||||
|
||||
class UDSAppConfig(AppConfig):
|
||||
name = 'uds'
|
||||
verbose_name = 'Universal Desktop Services'
|
||||
|
||||
def ready(self) -> None:
|
||||
# We have to take care with this, because it's supposed to be executed
|
||||
# with ANY command from manage.
|
||||
logger.debug('Initializing app (ready) ***************')
|
||||
|
||||
# Now, ensures that all dynamic elements are loaded and present
|
||||
# To make sure that the packages are already initialized at this point
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import services
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import auths
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import mfas
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import osmanagers
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import notifiers
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import transports
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import reports
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import dispatchers
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import plugins
|
||||
|
||||
# pylint: disable=unused-import,import-outside-toplevel
|
||||
from . import REST
|
||||
|
||||
|
||||
default_app_config = 'uds.UDSAppConfig'
|
||||
|
||||
|
||||
# Sets up several sqlite non existing methodsm and some optimizations on sqlite
|
||||
# pylint: disable=unused-argument
|
||||
@receiver(connection_created)
|
||||
def extend_sqlite(connection: typing.Any = None, **kwargs: typing.Any) -> None:
|
||||
if connection and connection.vendor == "sqlite":
|
||||
logger.debug(f'Connection vendor for %s is sqlite, extending methods', connection)
|
||||
cursor = connection.cursor()
|
||||
cursor.execute('PRAGMA synchronous=OFF')
|
||||
cursor.execute('PRAGMA cache_size=-16384')
|
||||
cursor.execute('PRAGMA temp_store=MEMORY')
|
||||
cursor.execute('PRAGMA journal_mode=WAL')
|
||||
cursor.execute('PRAGMA mmap_size=67108864')
|
||||
connection.connection.create_function("MIN", 2, min)
|
||||
connection.connection.create_function("MAX", 2, max)
|
||||
@@ -1,30 +0,0 @@
|
||||
# -*- 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
|
||||
"""
|
||||
@@ -1,69 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# 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 typing
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.middleware import csrf
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core.auths.auth import weblogin_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
@weblogin_required(role=consts.UserRole.ADMIN)
|
||||
def index(request: 'HttpRequest') -> HttpResponse:
|
||||
# Gets csrf token
|
||||
csrf_token = csrf.get_token(request)
|
||||
|
||||
return render(
|
||||
request,
|
||||
'uds/admin/index.html',
|
||||
{'csrf_field': consts.auth.CSRF_FIELD, 'csrf_token': csrf_token},
|
||||
)
|
||||
|
||||
# from django.template import RequestContext, loader
|
||||
# @weblogin_required(role=consts.Roles.ADMIN)
|
||||
# def tmpl(request: 'HttpRequest', template: str) -> HttpResponse:
|
||||
# try:
|
||||
# t = loader.get_template('uds/admin/tmpl/' + template + ".html")
|
||||
# c = RequestContext(request)
|
||||
# resp = t.render(c.flatten())
|
||||
# except Exception as e:
|
||||
# logger.debug('Exception getting template: %s', e)
|
||||
# resp = _('requested a template that do not exist')
|
||||
# return HttpResponse(resp, content_type="text/plain")
|
||||
@@ -1,37 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012 Virtual Cable S.L.
|
||||
# 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
|
||||
"""
|
||||
# pyright: reportUnusedImport=false
|
||||
from uds.core.util.config import Config
|
||||
from .authenticator import IPAuth
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
@@ -1,163 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-2022 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 logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_noop as _
|
||||
|
||||
from uds.core import auths, types
|
||||
from uds.core.types.states import State
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import net
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core import environment
|
||||
|
||||
|
||||
class IPAuth(auths.Authenticator):
|
||||
type_name = _('IP Authenticator')
|
||||
type_type = 'IPAuth'
|
||||
type_description = _('IP Authenticator')
|
||||
icon_file = 'auth.png'
|
||||
|
||||
# Allow mfa data on user form
|
||||
mfa_data_enabled = False
|
||||
|
||||
needs_password = False
|
||||
label_username = _('IP')
|
||||
label_groupname = _('IP Range')
|
||||
|
||||
block_user_on_failures = False
|
||||
|
||||
accepts_proxy = gui.CheckBoxField(
|
||||
label=_('Accept proxy'),
|
||||
default=False,
|
||||
order=50,
|
||||
tooltip=_(
|
||||
'If checked, requests via proxy will get FORWARDED ip address'
|
||||
' (take care with this bein checked, can take internal IP addresses from internet)'
|
||||
),
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
old_field_name='acceptProxy',
|
||||
)
|
||||
|
||||
allowed_in_networks = gui.TextField(
|
||||
order=50,
|
||||
label=_('Allowed only from this networks'),
|
||||
default='',
|
||||
tooltip=_(
|
||||
'This authenticator will be allowed only from these networks. Leave empty to allow all networks'
|
||||
),
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
old_field_name='visibleFromNets',
|
||||
)
|
||||
|
||||
def get_ip(self, request: 'types.requests.ExtendedHttpRequest') -> str:
|
||||
ip = request.ip_proxy if self.accepts_proxy.as_bool() else request.ip
|
||||
logger.debug('Client IP: %s', ip)
|
||||
# If ipv4 on ipv6, we must remove the ipv6 prefix
|
||||
if ':' in ip and '.' in ip:
|
||||
ip = ip.split(':')[-1]
|
||||
return ip
|
||||
|
||||
def mfa_identifier(self, username: str) -> str:
|
||||
try:
|
||||
return self.db_obj().users.get(name=username.lower(), state=State.ACTIVE).mfa_data
|
||||
except Exception: # nosec: This is a "not found" exception or any other db exception
|
||||
pass
|
||||
return ''
|
||||
|
||||
def get_groups(self, username: str, groups_manager: 'auths.GroupsManager') -> None:
|
||||
# these groups are a bit special. They are in fact ip-ranges, and we must check that the ip is in betwen
|
||||
# The ranges are stored in group names
|
||||
for g in groups_manager.enumerate_groups_name():
|
||||
try:
|
||||
if net.contains(g, username):
|
||||
groups_manager.validate(g)
|
||||
except Exception as e:
|
||||
logger.error('Invalid network for IP auth: %s', e)
|
||||
|
||||
def is_ip_allowed(self, request: 'types.requests.ExtendedHttpRequest') -> bool:
|
||||
"""
|
||||
Used by the login interface to determine if the authenticator is visible on the login page.
|
||||
"""
|
||||
valid_networks = self.allowed_in_networks.value.strip()
|
||||
# If has networks and not in any of them, not visible
|
||||
if valid_networks and not net.contains(valid_networks, request.ip):
|
||||
return False
|
||||
return super().is_ip_allowed(request)
|
||||
|
||||
def authenticate(
|
||||
self,
|
||||
username: str,
|
||||
credentials: str, # pylint: disable=unused-argument
|
||||
groups_manager: 'auths.GroupsManager',
|
||||
request: 'types.requests.ExtendedHttpRequest',
|
||||
) -> types.auth.AuthenticationResult:
|
||||
# If credentials is a dict, that can't be sent directly from web interface, we allow entering
|
||||
if username == self.get_ip(request):
|
||||
self.get_groups(username, groups_manager)
|
||||
return types.auth.SUCCESS_AUTH
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
@staticmethod
|
||||
def test(env: 'environment.Environment', data: 'types.core.ValuesType') -> 'types.core.TestResult':
|
||||
return types.core.TestResult(True)
|
||||
|
||||
def check(self) -> str:
|
||||
return _("All seems to be fine.")
|
||||
|
||||
def get_javascript(self, request: 'types.requests.ExtendedHttpRequest') -> typing.Optional[str]:
|
||||
# We will authenticate ip here, from request.ip
|
||||
# If valid, it will simply submit form with ip submited and a cached generated random password
|
||||
ip = self.get_ip(request)
|
||||
gm = auths.GroupsManager(self.db_obj())
|
||||
self.get_groups(ip, gm)
|
||||
|
||||
if gm.has_valid_groups() and self.db_obj().is_user_allowed(ip, True):
|
||||
return (
|
||||
'function setVal(element, value) {{\n' # nosec: no user input, password is always EMPTY
|
||||
' document.getElementById(element).value = value;\n'
|
||||
'}}\n'
|
||||
f'setVal("id_user", "{ip}");\n'
|
||||
'setVal("id_password", "");\n'
|
||||
'document.getElementById("loginform").submit();\n'
|
||||
)
|
||||
|
||||
return 'alert("invalid authhenticator"); window.location.reload();'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "IP Authenticator"
|
||||
@@ -1,34 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-2019 Virtual Cable S.L.
|
||||
# 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
|
||||
"""
|
||||
# pyright: reportUnusedImport=false
|
||||
from .authenticator import InternalDBAuth
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,217 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-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 logging
|
||||
import typing
|
||||
|
||||
import dns.resolver
|
||||
import dns.reversename
|
||||
from django.utils.translation import gettext_noop as _
|
||||
|
||||
from uds.core import auths, types
|
||||
from uds.core.auths.auth import log_login
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core.types.states import State
|
||||
from uds.core.ui import gui
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds import models
|
||||
from uds.core import environment
|
||||
from uds.core.types.requests import ExtendedHttpRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InternalDBAuth(auths.Authenticator):
|
||||
type_name = _('Internal Database')
|
||||
type_type = 'InternalDBAuth'
|
||||
type_description = _('Internal dabasase authenticator. Doesn\'t use external sources')
|
||||
icon_file = 'auth.png'
|
||||
|
||||
# If we need to enter the password for this user
|
||||
needs_password = True
|
||||
|
||||
# This is the only internal source
|
||||
external_source = False
|
||||
|
||||
# Allow mfa data on user form
|
||||
mfa_data_enabled = True
|
||||
|
||||
unique_by_host = gui.CheckBoxField(
|
||||
label=_('Different user for each host'),
|
||||
order=1,
|
||||
tooltip=_('If checked, each host will have a different user name'),
|
||||
default=False,
|
||||
readonly=True,
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
old_field_name='differentForEachHost',
|
||||
)
|
||||
reverse_dns = gui.CheckBoxField(
|
||||
label=_('Reverse DNS'),
|
||||
order=2,
|
||||
tooltip=_('If checked, the host will be reversed dns'),
|
||||
default=False,
|
||||
readonly=True,
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
old_field_name='reverseDns',
|
||||
)
|
||||
accepts_proxy = gui.CheckBoxField(
|
||||
label=_('Accept proxy'),
|
||||
order=3,
|
||||
default=False,
|
||||
tooltip=_(
|
||||
'If checked, requests via proxy will get FORWARDED ip address (take care with this bein checked, can take internal IP addresses from internet)'
|
||||
),
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
old_field_name='acceptProxy',
|
||||
)
|
||||
|
||||
def get_ip(self, request: 'ExtendedHttpRequest') -> str:
|
||||
ip = request.ip_proxy if self.accepts_proxy.as_bool() else request.ip # pylint: disable=maybe-no-member
|
||||
if self.reverse_dns.as_bool():
|
||||
try:
|
||||
ptr_resolv = dns.resolver.query(dns.reversename.from_address(ip).to_text(), 'PTR')
|
||||
return str(ptr_resolv[0]) # pyright: ignore[reportUnknownArgumentType]
|
||||
except Exception:
|
||||
# if we can't get the reverse, we will use the ip
|
||||
pass
|
||||
return ip
|
||||
|
||||
def mfa_identifier(self, username: str) -> str:
|
||||
try:
|
||||
return self.db_obj().users.get(name=username.lower(), state=State.ACTIVE).mfa_data
|
||||
except Exception: # nosec: This is a "not found" exception or any other db exception
|
||||
pass
|
||||
return ''
|
||||
|
||||
def transformed_username(self, username: str, request: 'ExtendedHttpRequest') -> str:
|
||||
username = username.lower()
|
||||
if self.unique_by_host.as_bool():
|
||||
ip_username = (request.ip_proxy if self.accepts_proxy.as_bool() else request.ip) + '-' + username
|
||||
# Duplicate basic user into username.
|
||||
auth = self.db_obj()
|
||||
# "Derived" users will belong to no group at all, because we will extract groups from "base" user
|
||||
# This way also, we protect from using forged "ip" + "username", because those will belong in fact to no group
|
||||
# and access will be denied
|
||||
grps: list['models.Group'] = []
|
||||
|
||||
try:
|
||||
usr = auth.users.get(name=username, state=State.ACTIVE)
|
||||
parent = usr.uuid
|
||||
grps = [g for g in usr.groups.all()]
|
||||
typing.cast(typing.Any, usr).id = typing.cast(typing.Any, usr).uuid = (
|
||||
None # cast to avoid pylance error
|
||||
)
|
||||
if usr.real_name.strip() == '':
|
||||
usr.real_name = usr.name
|
||||
usr.name = ip_username
|
||||
usr.parent = parent
|
||||
usr.save()
|
||||
except Exception: # nosec: intentionally
|
||||
pass # User already exists
|
||||
username = ip_username
|
||||
|
||||
# Update groups of user
|
||||
try:
|
||||
usr = auth.users.get(name=ip_username, state=State.ACTIVE)
|
||||
usr.groups.clear()
|
||||
for grp in grps:
|
||||
usr.groups.add(grp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return username
|
||||
|
||||
def authenticate(
|
||||
self,
|
||||
username: str,
|
||||
credentials: str,
|
||||
groups_manager: 'auths.GroupsManager',
|
||||
request: 'ExtendedHttpRequest',
|
||||
) -> types.auth.AuthenticationResult:
|
||||
username = username.lower()
|
||||
auth_db = self.db_obj()
|
||||
try:
|
||||
user: 'models.User' = auth_db.users.get(name=username, state=State.ACTIVE)
|
||||
except Exception:
|
||||
log_login(request, self.db_obj(), username, 'Invalid user', as_error=True)
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
if user.parent: # Direct auth not allowed for "derived" users
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# Internal Db Auth has its own groups. (That is, no external source). If a group is active it is valid
|
||||
if CryptoManager().check_hash(credentials, user.password):
|
||||
groups_manager.validate([g.name for g in user.groups.all()])
|
||||
return types.auth.SUCCESS_AUTH
|
||||
|
||||
log_login(request, self.db_obj(), username, 'Invalid password', as_error=True)
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
def get_groups(self, username: str, groups_manager: 'auths.GroupsManager') -> None:
|
||||
auth_db = self.db_obj()
|
||||
try:
|
||||
user: 'models.User' = auth_db.users.get(name=username.lower(), state=State.ACTIVE)
|
||||
except Exception:
|
||||
return
|
||||
grps = [g.name for g in user.groups.all()]
|
||||
if user.parent:
|
||||
try:
|
||||
parent = auth_db.users.get(uuid=user.parent, state=State.ACTIVE)
|
||||
grps.extend([g.name for g in parent.groups.all()])
|
||||
except Exception:
|
||||
pass
|
||||
groups_manager.validate(grps)
|
||||
|
||||
def get_real_name(self, username: str) -> str:
|
||||
# Return the real name of the user, if it is set
|
||||
try:
|
||||
user = self.db_obj().users.get(name=username.lower(), state=State.ACTIVE)
|
||||
return user.real_name or username
|
||||
except Exception:
|
||||
return super().get_real_name(username)
|
||||
|
||||
def create_user(self, user_data: dict[str, typing.Any]) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def test(env: 'environment.Environment', data: 'types.core.ValuesType') -> 'types.core.TestResult':
|
||||
return types.core.TestResult(True)
|
||||
|
||||
def check(self) -> str:
|
||||
return _("All seems fine in the authenticator.")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Internal DB Authenticator Authenticator"
|
||||
@@ -1,38 +0,0 @@
|
||||
# -*- 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.
|
||||
|
||||
|
||||
"""
|
||||
Sample authenticator. We import here the module, and uds.auths module will
|
||||
take care of registering it as provider
|
||||
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
# pyright: reportUnusedImport=false
|
||||
from .authenticator import OAuth2Authenticator
|
||||
@@ -1,559 +0,0 @@
|
||||
#
|
||||
# Copyright (c) 2024 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 logging
|
||||
import hashlib
|
||||
import secrets
|
||||
import typing
|
||||
import collections.abc
|
||||
import urllib.parse
|
||||
from base64 import b64encode
|
||||
|
||||
import jwt
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_noop as _
|
||||
|
||||
from . import types as oauth2_types, consts as oauth2_consts
|
||||
from uds.core import auths, consts, exceptions, types
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import fields, auth as auth_utils, security
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.http import HttpRequest
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuth2Authenticator(auths.Authenticator):
|
||||
"""
|
||||
This class represents an OAuth2 Authenticator.
|
||||
"""
|
||||
|
||||
type_name = _('OAuth2 Authenticator')
|
||||
type_type = 'OAuth2Authenticator'
|
||||
type_description = _('OAuth2 Authenticator')
|
||||
icon_file = 'oauth2.png'
|
||||
|
||||
authorization_endpoint = gui.TextField(
|
||||
length=256,
|
||||
label=_('Authorization endpoint'),
|
||||
order=10,
|
||||
tooltip=_('Authorization endpoint for OAuth2.'),
|
||||
required=True,
|
||||
tab=_('Server'),
|
||||
)
|
||||
client_id = gui.TextField(
|
||||
length=128,
|
||||
label=_('Client ID'),
|
||||
order=2,
|
||||
tooltip=_('Client ID for OAuth2.'),
|
||||
required=True,
|
||||
tab=_('Server'),
|
||||
)
|
||||
client_secret = gui.PasswordField(
|
||||
length=128,
|
||||
label=_('Client Secret'),
|
||||
order=3,
|
||||
tooltip=_('Client secret for OAuth2.'),
|
||||
required=True,
|
||||
tab=_('Server'),
|
||||
)
|
||||
scope = gui.TextField(
|
||||
length=128,
|
||||
label=_('Scope'),
|
||||
order=4,
|
||||
tooltip=_('Scope for OAuth2.'),
|
||||
required=True,
|
||||
tab=_('Server'),
|
||||
)
|
||||
common_groups = gui.TextField(
|
||||
length=128,
|
||||
label=_('Common Groups'),
|
||||
order=5,
|
||||
tooltip=_('User will be assigned to this groups once authenticated. Comma separated list of groups'),
|
||||
required=False,
|
||||
tab=_('Server'),
|
||||
)
|
||||
|
||||
# Advanced options
|
||||
redirection_endpoint = gui.TextField(
|
||||
length=128,
|
||||
label=_('Redirection endpoint'),
|
||||
order=90,
|
||||
tooltip=_('Redirection endpoint for OAuth2. (Filled by UDS)'),
|
||||
required=False,
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
)
|
||||
response_type = gui.ChoiceField(
|
||||
label=_('Response type'),
|
||||
order=91,
|
||||
tooltip=_('Response type for OAuth2.'),
|
||||
required=True,
|
||||
default='code',
|
||||
choices=[
|
||||
gui.choice_item(v, v.as_text)
|
||||
for v in oauth2_types.ResponseType
|
||||
],
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
)
|
||||
# In case of code, we need to get the token from the token endpoint
|
||||
token_endpoint = gui.TextField(
|
||||
length=128,
|
||||
label=_('Token endpoint'),
|
||||
order=92,
|
||||
tooltip=_('Token endpoint for OAuth2. Only required for "code" response type.'),
|
||||
required=False,
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
)
|
||||
info_endpoint = gui.TextField(
|
||||
length=128,
|
||||
label=_('User information endpoint'),
|
||||
order=93,
|
||||
tooltip=_('User information endpoint for OAuth2. Only required for "code" response type.'),
|
||||
required=False,
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
)
|
||||
public_key = gui.TextField(
|
||||
length=16384,
|
||||
lines=3,
|
||||
label=_('Public Key'),
|
||||
order=94,
|
||||
tooltip=_('Provided by Oauth2 provider'),
|
||||
required=False,
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
)
|
||||
logout_url = gui.TextField(
|
||||
length=256,
|
||||
label=_('Logout URL'),
|
||||
order=95,
|
||||
tooltip=_('URL to logout from OAuth2 provider. Allows {token} placeholder.'),
|
||||
required=False,
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
)
|
||||
|
||||
username_attr = fields.username_attr_field(order=100)
|
||||
groupname_attr = fields.groupname_attr_field(order=101)
|
||||
realname_attr = fields.realname_attr_field(order=102)
|
||||
|
||||
# Non serializable variables
|
||||
session: typing.ClassVar['requests.Session'] = security.secure_requests_session()
|
||||
|
||||
def initialize(self, values: typing.Optional[dict[str, typing.Any]]) -> None:
|
||||
if not values:
|
||||
return
|
||||
|
||||
if ' ' in values['name']:
|
||||
raise exceptions.ui.ValidationError(
|
||||
gettext('This kind of Authenticator does not support white spaces on field NAME')
|
||||
)
|
||||
|
||||
auth_utils.validate_regex_field(self.username_attr)
|
||||
auth_utils.validate_regex_field(self.username_attr)
|
||||
|
||||
if self.response_type.value in (
|
||||
oauth2_types.ResponseType.CODE,
|
||||
oauth2_types.ResponseType.PKCE,
|
||||
oauth2_types.ResponseType.OPENID_CODE,
|
||||
):
|
||||
if self.common_groups.value.strip() == '':
|
||||
raise exceptions.ui.ValidationError(
|
||||
gettext('Common groups is required for "code" response types')
|
||||
)
|
||||
if self.token_endpoint.value.strip() == '':
|
||||
raise exceptions.ui.ValidationError(
|
||||
gettext('Token endpoint is required for "code" response types')
|
||||
)
|
||||
# infoEndpoint will not be necesary if the response of tokenEndpoint contains the user info
|
||||
|
||||
if self.response_type.value == 'openid+token_id':
|
||||
# Ensure we have a public key
|
||||
if self.public_key.value.strip() == '':
|
||||
raise exceptions.ui.ValidationError(
|
||||
gettext('Public key is required for "openid+token_id" response type')
|
||||
)
|
||||
|
||||
if self.redirection_endpoint.value.strip() == '' and self.db_obj() and '_request' in values:
|
||||
request: 'HttpRequest' = values['_request']
|
||||
self.redirection_endpoint.value = request.build_absolute_uri(self.callback_url())
|
||||
|
||||
def auth_callback(
|
||||
self,
|
||||
parameters: 'types.auth.AuthCallbackParams',
|
||||
groups_manager: 'auths.GroupsManager',
|
||||
request: 'types.requests.ExtendedHttpRequest',
|
||||
) -> types.auth.AuthenticationResult:
|
||||
match oauth2_types.ResponseType(self.response_type.value):
|
||||
case oauth2_types.ResponseType.CODE | oauth2_types.ResponseType.PKCE:
|
||||
return self.auth_callback_code(parameters, groups_manager, request)
|
||||
# case 'token':
|
||||
case oauth2_types.ResponseType.TOKEN:
|
||||
return self.auth_callback_token(parameters, groups_manager, request)
|
||||
# case 'openid+code':
|
||||
case oauth2_types.ResponseType.OPENID_CODE:
|
||||
return self.auth_callback_openid_code(parameters, groups_manager, request)
|
||||
# case 'openid+token_id':
|
||||
case oauth2_types.ResponseType.OPENID_ID_TOKEN:
|
||||
return self.auth_callback_openid_id_token(parameters, groups_manager, request)
|
||||
|
||||
def logout(
|
||||
self,
|
||||
request: 'types.requests.ExtendedHttpRequest',
|
||||
username: str,
|
||||
) -> types.auth.AuthenticationResult:
|
||||
if self.logout_url.value.strip() == '' or (token := self.retrieve_token(request)) == '':
|
||||
return types.auth.SUCCESS_AUTH
|
||||
|
||||
return types.auth.AuthenticationResult(
|
||||
types.auth.AuthenticationState.SUCCESS,
|
||||
url=self.logout_url.value.replace('{token}', urllib.parse.quote(token)),
|
||||
)
|
||||
|
||||
def get_javascript(self, request: 'HttpRequest') -> typing.Optional[str]:
|
||||
"""
|
||||
We will here compose the azure request and send it via http-redirect
|
||||
"""
|
||||
return f'window.location="{self.get_login_url()}";'
|
||||
|
||||
def get_groups(self, username: str, groups_manager: 'auths.GroupsManager') -> None:
|
||||
data = self.storage.read_pickled(username)
|
||||
if not data:
|
||||
return
|
||||
groups_manager.validate(data[1])
|
||||
|
||||
def get_real_name(self, username: str) -> str:
|
||||
data = self.storage.read_pickled(username)
|
||||
if not data:
|
||||
return username
|
||||
return data[0]
|
||||
|
||||
# own methods
|
||||
def get_public_keys(self) -> list[typing.Any]: # In fact, any of the PublicKey types
|
||||
# Get certificates in self.publicKey.value, encoded as PEM
|
||||
# Return a list of certificates in DER format
|
||||
return [cert.public_key() for cert in fields.get_certificates_from_field(self.public_key)]
|
||||
|
||||
def code_verifier_and_challenge(self) -> tuple[str, str]:
|
||||
"""Generate a code verifier and a code challenge for PKCE
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: Code verifier and code challenge
|
||||
"""
|
||||
code_verifier = ''.join(secrets.choice(oauth2_consts.PKCE_ALPHABET) for _ in range(128))
|
||||
code_challenge = (
|
||||
b64encode(hashlib.sha256(code_verifier.encode()).digest(), altchars=b'-_')
|
||||
.decode()
|
||||
.rstrip('=') # remove padding
|
||||
)
|
||||
|
||||
return code_verifier, code_challenge
|
||||
|
||||
def get_login_url(self) -> str:
|
||||
"""
|
||||
:type request: django.http.request.HttpRequest
|
||||
"""
|
||||
state: str = secrets.token_urlsafe(oauth2_consts.STATE_LENGTH)
|
||||
response_type = oauth2_types.ResponseType(self.response_type.value)
|
||||
|
||||
param_dict: dict[str, str] = {
|
||||
'response_type': response_type.for_query,
|
||||
'client_id': self.client_id.value,
|
||||
'redirect_uri': self.redirection_endpoint.value,
|
||||
'scope': self.scope.value.replace(',', ' '),
|
||||
'state': state,
|
||||
}
|
||||
|
||||
match response_type:
|
||||
case oauth2_types.ResponseType.CODE | oauth2_types.ResponseType.TOKEN:
|
||||
# Code or token flow
|
||||
# Simply store state, no code_verifier, store "none" as code_verifier to later restore it
|
||||
self.cache.put(state, 'none', 3600)
|
||||
case oauth2_types.ResponseType.OPENID_CODE | oauth2_types.ResponseType.OPENID_ID_TOKEN:
|
||||
# OpenID flow
|
||||
nonce = secrets.token_urlsafe(oauth2_consts.STATE_LENGTH)
|
||||
self.cache.put(state, nonce, 3600) # Store nonce
|
||||
# Fix scope to ensure openid is present
|
||||
if 'openid' not in param_dict['scope']:
|
||||
param_dict['scope'] = 'openid ' + param_dict['scope']
|
||||
# Append nonce
|
||||
param_dict['nonce'] = nonce
|
||||
# Add response_mode
|
||||
param_dict['response_mode'] = 'form_post' # ['query', 'fragment', 'form_post']
|
||||
case oauth2_types.ResponseType.PKCE:
|
||||
# PKCE flow
|
||||
code_verifier, code_challenge = self.code_verifier_and_challenge()
|
||||
param_dict['code_challenge'] = code_challenge
|
||||
param_dict['code_challenge_method'] = 'S256'
|
||||
self.cache.put(state, code_verifier, 3600)
|
||||
|
||||
# Nonce only is used
|
||||
if False:
|
||||
param_dict['nonce'] = nonce
|
||||
|
||||
if False:
|
||||
param_dict['response_mode'] = 'form_post' # ['query', 'fragment', 'form_post']
|
||||
|
||||
params = urllib.parse.urlencode(param_dict)
|
||||
|
||||
return self.authorization_endpoint.value + '?' + params
|
||||
|
||||
def request_token(self, code: str, code_verifier: typing.Optional[str] = None) -> 'oauth2_types.TokenInfo':
|
||||
"""Request a token from the token endpoint using the code received from the authorization endpoint
|
||||
|
||||
Args:
|
||||
code (str): Code received from the authorization endpoint
|
||||
|
||||
Returns:
|
||||
TokenInfo: Token received from the token endpoint
|
||||
"""
|
||||
param_dict = {
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': self.client_id.value,
|
||||
'client_secret': self.client_secret.value,
|
||||
'redirect_uri': self.redirection_endpoint.value,
|
||||
'code': code,
|
||||
}
|
||||
if code_verifier:
|
||||
param_dict['code_verifier'] = code_verifier
|
||||
|
||||
response = OAuth2Authenticator.session.post(
|
||||
self.token_endpoint.value, data=param_dict, timeout=consts.system.COMMS_TIMEOUT
|
||||
)
|
||||
logger.debug('Token request: %s %s', response.status_code, response.text)
|
||||
|
||||
if not response.ok:
|
||||
raise Exception('Error requesting token: {}'.format(response.text))
|
||||
|
||||
return oauth2_types.TokenInfo.from_dict(response.json())
|
||||
|
||||
def request_userinfo(self, token: 'oauth2_types.TokenInfo') -> dict[str, typing.Any]:
|
||||
"""Request user info from the info endpoint using the token received from the token endpoint
|
||||
|
||||
If the token endpoint returns the user info, this method will not be used
|
||||
|
||||
Args:
|
||||
token (TokenInfo): Token info received from the token endpoint
|
||||
|
||||
Returns:
|
||||
dict[str, typing.Any]: User info received from the info endpoint
|
||||
"""
|
||||
userinfo: dict[str, typing.Any]
|
||||
|
||||
if self.info_endpoint.value.strip() == '':
|
||||
if not token.info:
|
||||
raise Exception('No user info endpoint and token does not contain user info')
|
||||
userinfo = token.info
|
||||
else:
|
||||
# Get user info
|
||||
req = OAuth2Authenticator.session.get(
|
||||
self.info_endpoint.value,
|
||||
headers={'Authorization': 'Bearer ' + token.access_token},
|
||||
timeout=consts.system.COMMS_TIMEOUT,
|
||||
)
|
||||
logger.debug('User info request: %s %s', req.status_code, req.text)
|
||||
|
||||
if not req.ok:
|
||||
raise Exception('Error requesting user info: {}'.format(req.text))
|
||||
|
||||
userinfo = req.json()
|
||||
return userinfo
|
||||
|
||||
def save_token(self, request: 'HttpRequest', token: str) -> None:
|
||||
request.session['oauth2_token'] = token
|
||||
|
||||
def retrieve_token(self, request: 'HttpRequest') -> str:
|
||||
return request.session.get('oauth2_token', '')
|
||||
|
||||
def process_userinfo(
|
||||
self, userinfo: collections.abc.Mapping[str, typing.Any], gm: 'auths.GroupsManager'
|
||||
) -> types.auth.AuthenticationResult:
|
||||
# After this point, we don't mind about the token, we only need to authenticate user
|
||||
# and get some basic info from it
|
||||
|
||||
username = ''.join(auth_utils.process_regex_field(self.username_attr.value, userinfo)).replace(' ', '_')
|
||||
if len(username) == 0:
|
||||
raise Exception('No username received')
|
||||
|
||||
realname = ''.join(auth_utils.process_regex_field(self.realname_attr.value, userinfo))
|
||||
|
||||
# Get groups
|
||||
groups = auth_utils.process_regex_field(self.groupname_attr.value, userinfo)
|
||||
# Append common groups
|
||||
groups.extend(self.common_groups.value.split(','))
|
||||
|
||||
# store groups for this username at storage, so we can check it at a later stage
|
||||
self.storage.save_pickled(username, [realname, groups])
|
||||
|
||||
# Validate common groups
|
||||
gm.validate(groups)
|
||||
|
||||
# We don't mind about the token, we only need to authenticate user
|
||||
# and if we are here, the user is authenticated, so we can return SUCCESS_AUTH
|
||||
return types.auth.AuthenticationResult(types.auth.AuthenticationState.SUCCESS, username=username)
|
||||
|
||||
def process_token_open_id(
|
||||
self, token_id: str, nonce: str, gm: 'auths.GroupsManager'
|
||||
) -> types.auth.AuthenticationResult:
|
||||
# Get token headers, to extract algorithm
|
||||
info = jwt.get_unverified_header(token_id)
|
||||
logger.debug('Token headers: %s', info)
|
||||
|
||||
# We may have multiple public keys, try them all
|
||||
# (We should only have one, but just in case)
|
||||
for key in self.get_public_keys():
|
||||
logger.debug('Key = %s', key)
|
||||
try:
|
||||
payload = jwt.decode(token, key=key, audience=self.client_id.value, algorithms=[info.get('alg', 'RSA256')]) # type: ignore
|
||||
# If reaches here, token is valid, raises jwt.InvalidTokenError otherwise
|
||||
logger.debug('Payload: %s', payload)
|
||||
if payload.get('nonce') != nonce:
|
||||
logger.error('Nonce does not match: %s != %s', payload.get('nonce'), nonce)
|
||||
else:
|
||||
logger.debug('Payload: %s', payload)
|
||||
# All is fine, get user & look for groups
|
||||
|
||||
# Process attributes from payload
|
||||
return self.process_userinfo(payload, gm)
|
||||
except (jwt.InvalidTokenError, IndexError):
|
||||
# logger.debug('Data was invalid: %s', e)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error('Error decoding token: %s', e)
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# All keys tested, none worked
|
||||
logger.error('Invalid token received on OAuth2 callback')
|
||||
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
def auth_callback_code(
|
||||
self,
|
||||
parameters: 'types.auth.AuthCallbackParams',
|
||||
gm: 'auths.GroupsManager',
|
||||
request: 'types.requests.ExtendedHttpRequest',
|
||||
) -> types.auth.AuthenticationResult:
|
||||
"""Process the callback for code authorization flow"""
|
||||
state = parameters.get_params.get('state', '')
|
||||
# Get and remove state from cache
|
||||
code_verifier = self.cache.pop(state)
|
||||
|
||||
if not state or not code_verifier:
|
||||
logger.error('Invalid state received on OAuth2 callback')
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# Get the code
|
||||
code = parameters.get_params.get('code', '')
|
||||
if code == '':
|
||||
logger.error('Invalid code received on OAuth2 callback')
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# Restore code_verifier "none" to None
|
||||
if code_verifier == 'none':
|
||||
code_verifier = None
|
||||
|
||||
token_info = self.request_token(code, code_verifier)
|
||||
# Store for later use
|
||||
self.save_token(request, token_info.access_token)
|
||||
return self.process_userinfo(self.request_userinfo(token_info), gm)
|
||||
|
||||
def auth_callback_token(
|
||||
self,
|
||||
parameters: 'types.auth.AuthCallbackParams',
|
||||
gm: 'auths.GroupsManager',
|
||||
request: 'types.requests.ExtendedHttpRequest',
|
||||
) -> types.auth.AuthenticationResult:
|
||||
"""Process the callback for PKCE authorization flow"""
|
||||
state = parameters.get_params.get('state', '')
|
||||
state_value = self.cache.pop(state)
|
||||
|
||||
if not state or not state_value:
|
||||
logger.error('Invalid state received on OAuth2 callback')
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# Get the token, token_type, expires
|
||||
token = oauth2_types.TokenInfo.from_dict(parameters.get_params)
|
||||
# Store for later use
|
||||
self.save_token(request, token.access_token)
|
||||
return self.process_userinfo(self.request_userinfo(token), gm)
|
||||
|
||||
def auth_callback_openid_code(
|
||||
self,
|
||||
parameters: 'types.auth.AuthCallbackParams',
|
||||
gm: 'auths.GroupsManager',
|
||||
request: 'types.requests.ExtendedHttpRequest',
|
||||
) -> types.auth.AuthenticationResult:
|
||||
"""Process the callback for OpenID authorization flow"""
|
||||
state = parameters.post_params.get('state', '')
|
||||
nonce = self.cache.pop(state)
|
||||
|
||||
if not state or not nonce:
|
||||
logger.error('Invalid state received on OAuth2 callback')
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# Get the code
|
||||
code = parameters.post_params.get('code', '')
|
||||
if code == '':
|
||||
logger.error('Invalid code received on OAuth2 callback')
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# Get the token, token_type, expires
|
||||
token = self.request_token(code)
|
||||
|
||||
if not token.id_token:
|
||||
logger.error('No id_token received on OAuth2 callback')
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# Store for later use
|
||||
self.save_token(request, token.access_token)
|
||||
return self.process_token_open_id(token.id_token, nonce, gm)
|
||||
|
||||
def auth_callback_openid_id_token(
|
||||
self,
|
||||
parameters: 'types.auth.AuthCallbackParams',
|
||||
gm: 'auths.GroupsManager',
|
||||
request: 'types.requests.ExtendedHttpRequest',
|
||||
) -> types.auth.AuthenticationResult:
|
||||
"""Process the callback for OpenID authorization flow"""
|
||||
state = parameters.post_params.get('state', '')
|
||||
nonce = self.cache.pop(state)
|
||||
|
||||
if not state or not nonce:
|
||||
logger.error('Invalid state received on OAuth2 callback')
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# Get the id_token
|
||||
id_token = parameters.post_params.get('id_token', '')
|
||||
if id_token == '':
|
||||
logger.error('Invalid id_token received on OAuth2 callback')
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# Store for later use
|
||||
self.save_token(request, id_token)
|
||||
return self.process_token_open_id(id_token, nonce, gm)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user