1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-11-22 00:24:29 +03:00

12 Commits

Author SHA1 Message Date
Adolfo Gómez García
d3e24f16f6 Updated server 2025-11-12 18:22:49 +01:00
Adolfo Gómez García
3952c57b69 Updating worflows after submodule creation 2025-11-12 16:23:41 +01:00
Adolfo Gómez García
88a99c7220 Updating worflows after submodule creation 2025-11-12 16:22:44 +01:00
Adolfo Gómez García
3e56748260 Moving doc to correct place 2025-11-12 16:22:16 +01:00
Adolfo Gómez García
31813f4f97 Add server as submodule 2025-11-12 00:49:47 +01:00
Adolfo Gómez García
6c6e2f6417 Remove server folder, preparing for submodule 2025-11-12 00:48:47 +01:00
Adolfo Gómez García
584ebe2e74 Merge branch 'master' of github.com:/VirtualCable/openuds 2025-11-12 00:48:20 +01:00
Adolfo Gómez García
7b28963dce Updated actor 2025-11-12 00:48:10 +01:00
Adolfo Gómez
d19086e7cc Merge pull request #152 from VirtualCable/dev/andres/master-req
Dev/andres/master req
2025-11-11 19:29:03 +01:00
Adolfo Gómez García
67a58d57cb Remove redundant exception handling in ModelHandler 2025-11-11 19:09:20 +01:00
Adolfo Gómez García
39a046bb23 Refactor type hinting and clean up whitespace in sorting methods 2025-11-11 18:44:08 +01:00
Adolfo Gómez García
ae16e78a4a Refactor error handling and improve sorting methods in REST handlers 2025-11-11 18:40:07 +01:00
1022 changed files with 7 additions and 292377 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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

Submodule actor updated: 10b407ced9...8af8b39db3

View File

@@ -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.

View File

@@ -1,6 +0,0 @@
====================
Quickstart Guide
====================
This guide walks you through the essential steps to get OpenUDS running quickly.

View File

@@ -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
=================
-

View File

@@ -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 youre using the default admin account, the credentials are:
- **Username:** ``root``
- **Password:** ``udsmam0``

1
server Submodule

Submodule server added at 6ac76a8817

View File

@@ -1,2 +0,0 @@
PYTHONPATH=./src:${PYTHONPATH}

View File

@@ -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

View File

@@ -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()

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 =

View File

@@ -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",
}

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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())

View File

@@ -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'])

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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

View File

@@ -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())

View File

@@ -1,8 +0,0 @@
/log/
/static/
.coverage
/uds/static/clients/
/*.sqlite3*
.hypothesis
htmlcov
docs

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,
},
},
}

View File

@@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
"""
Url patterns for UDS project (Django)
"""
from django.urls import include, path
urlpatterns = [
path('', include('uds.urls')),
]

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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 {}

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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()

View File

@@ -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'

View File

@@ -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

View File

@@ -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()
)

View File

@@ -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
)

View File

@@ -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'

View File

@@ -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),
)

View File

@@ -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]))

View File

@@ -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,
)

View File

@@ -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())

View File

@@ -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

View File

@@ -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}

View File

@@ -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),
)

View File

@@ -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),
)

View File

@@ -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),
)

View File

@@ -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()

View File

@@ -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))

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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(),
)

View File

@@ -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')

View File

@@ -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())
]

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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
]

View File

@@ -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()

View File

@@ -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]

View File

@@ -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}

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 ']'

View File

@@ -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)

View File

@@ -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
"""

View File

@@ -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")

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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