forked from shaba/openuds
Compare commits
103 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
dd08257fb9 | ||
|
9d0df6cfae | ||
|
7bd0d571e6 | ||
|
ad269b3c28 | ||
|
f3dd5753a3 | ||
|
13336b966e | ||
|
a76989d885 | ||
|
5f0e5a5dfe | ||
|
cfbce5aef5 | ||
|
d2cb4356f0 | ||
|
4f4f1f24fd | ||
|
65d38d8722 | ||
|
b16cea984c | ||
|
7769351d42 | ||
|
bf635a5e9a | ||
|
ae2ffccbc3 | ||
|
a005bf1ca0 | ||
|
4de443395d | ||
|
9f2bc5417f | ||
|
c6d1bf450c | ||
|
cf21936f41 | ||
|
5d9c8ee53f | ||
|
7d3bfb5d3b | ||
|
b474e63924 | ||
|
d48747abff | ||
|
8b3ad295cc | ||
|
aa677353ad | ||
|
9c6c4078b1 | ||
|
9fba2b45ad | ||
|
71582fc415 | ||
|
0d1d38c18a | ||
|
4ec8841a57 | ||
|
8c6390733c | ||
|
98f56ee58b | ||
|
1c01c35a87 | ||
|
673d1b6813 | ||
|
1ba12bb82d | ||
|
f90f108869 | ||
|
88c3f9077b | ||
|
2a01df542d | ||
|
2733444355 | ||
|
6692e5ce6d | ||
|
38b3318704 | ||
|
ccec281e0d | ||
|
230187d9ee | ||
|
092bb83001 | ||
|
ac62aed420 | ||
|
e16be78ad5 | ||
|
28319b216f | ||
|
739b0c7f81 | ||
|
e5e8ad5fbd | ||
|
86ebd7766e | ||
|
4f0ea76666 | ||
|
18e9cab9ef | ||
|
6053e34d1d | ||
|
11041ff44f | ||
|
98826504d6 | ||
|
3a990e19a6 | ||
|
8a150439ae | ||
|
e79753748e | ||
|
a8a9b24596 | ||
|
f24c77f20a | ||
|
d2fa5e38d0 | ||
|
ada5374db5 | ||
|
93ba05f6cb | ||
|
94cf5582e2 | ||
|
afcfffbd29 | ||
|
d1329849f3 | ||
|
f5d2776478 | ||
|
0496117fc1 | ||
|
fcdf599e18 | ||
|
05b6bebf36 | ||
|
cdbc8d7ba1 | ||
|
072a722b09 | ||
|
2d2e2d7b1f | ||
|
f4da75cea9 | ||
|
1c65722d24 | ||
|
8783db925f | ||
|
5e61871091 | ||
|
80b26446f6 | ||
|
a0ac50d9c2 | ||
|
6094f55182 | ||
|
11d9c77a79 | ||
|
76e67b1f63 | ||
|
64fc61a2d6 | ||
|
57b19757b9 | ||
|
aec2f5b57f | ||
|
77e021a371 | ||
|
4db98684d3 | ||
|
a948d5eeb1 | ||
|
c7e6857492 | ||
|
aaa4216862 | ||
|
098396be87 | ||
|
d02c693202 | ||
|
cb11a26fbe | ||
|
43934d425f | ||
|
5b499de983 | ||
|
00d9f5759d | ||
|
ec02f63cac | ||
|
0de655d14f | ||
|
68e327847b | ||
|
81ea07f0a0 | ||
|
d7540c3305 |
@@ -1,43 +0,0 @@
|
||||
<IfModule ssl_module>
|
||||
|
||||
#Listen 443
|
||||
|
||||
<VirtualHost *:443>
|
||||
SSLEngine On
|
||||
SSLCertificateFile /etc/httpd2/ssl.crt/openuds-server.crt
|
||||
SSLCertificateKeyFile /etc/httpd2/ssl.key/openuds-server.key
|
||||
|
||||
ServerName openuds.example.com
|
||||
ServerAdmin webmaster@openuds.example.com
|
||||
|
||||
DocumentRoot /usr/share/openuds
|
||||
|
||||
Alias /favicon.ico /usr/share/openuds/uds/static/modern/img/favicon.ico
|
||||
Alias /static/ /usr/share/openuds/uds/static/
|
||||
Alias /uds/res/ /usr/share/openuds/uds/static/
|
||||
|
||||
|
||||
LogLevel warn
|
||||
ErrorLog /var/log/openuds/error.log
|
||||
# CustomLog /var/log/openuds/access.log combined
|
||||
|
||||
WSGIScriptReloading On
|
||||
WSGIDaemonProcess openuds processes=2 threads=10 python-path=/usr/share/openuds user=openuds group=openuds display-name=%{GROUP}
|
||||
WSGIProcessGroup openuds
|
||||
WSGIApplicationGroup openuds
|
||||
WSGIPassAuthorization On
|
||||
WSGIScriptAlias / /usr/share/openuds/server/wsgi.py
|
||||
|
||||
|
||||
<Directory /usr/share/openuds/uds>
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
<Directory /usr/share/openuds/server>
|
||||
<Files wsgi.py>
|
||||
Require all granted
|
||||
</Files>
|
||||
</Directory>
|
||||
|
||||
</VirtualHost>
|
||||
</IfModule>
|
@@ -1,34 +0,0 @@
|
||||
|
||||
#Listen 443
|
||||
|
||||
<VirtualHost *:80>
|
||||
|
||||
DocumentRoot /usr/share/openuds
|
||||
|
||||
Alias /favicon.ico /usr/share/openuds/uds/static/modern/img/favicon.ico
|
||||
Alias /static/ /usr/share/openuds/uds/static/
|
||||
Alias /uds/res/ /usr/share/openuds/uds/static/
|
||||
|
||||
LogLevel warn
|
||||
ErrorLog /var/log/openuds/error.log
|
||||
# CustomLog /var/log/openuds/access.log combined
|
||||
|
||||
WSGIScriptReloading On
|
||||
WSGIDaemonProcess openuds processes=2 threads=10 python-path=/usr/share/openuds user=openuds group=openuds display-name=%{GROUP}
|
||||
WSGIProcessGroup openuds
|
||||
WSGIApplicationGroup openuds
|
||||
WSGIPassAuthorization On
|
||||
WSGIScriptAlias / /usr/share/openuds/server/wsgi.py
|
||||
|
||||
|
||||
<Directory /usr/share/openuds/uds>
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
<Directory /usr/share/openuds/server>
|
||||
<Files wsgi.py>
|
||||
Require all granted
|
||||
</Files>
|
||||
</Directory>
|
||||
|
||||
</VirtualHost>
|
@@ -1,79 +0,0 @@
|
||||
upstream uds_server {
|
||||
server unix:/run/openuds/socket fail_timeout=10s;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_proto $thescheme {
|
||||
default $scheme;
|
||||
https https;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
# SSL configuration
|
||||
#
|
||||
listen 443 ssl http2 default_server;
|
||||
listen [::]:443 ssl http2 default_server;
|
||||
|
||||
ssl_protocols TLSv1.2;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_timeout 10m;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
#resolver $DNS-IP-1 $DNS-IP-2 valid=300s;
|
||||
resolver_timeout 5s;
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
|
||||
ssl_certificate /var/lib/ssl/certs/nginx-openuds.cert;
|
||||
ssl_certificate_key /var/lib/ssl/private/nginx-openuds.key;
|
||||
|
||||
root /usr/share/openuds/;
|
||||
|
||||
# Add index.php to the list if you are using PHP
|
||||
index index.html;
|
||||
|
||||
server_name _;
|
||||
|
||||
# Activate GZIP
|
||||
# In our app, saves around 80% or the traffic.
|
||||
#
|
||||
gzip on;
|
||||
gzip_proxied any;
|
||||
# text/html is always included
|
||||
gzip_types
|
||||
text/css
|
||||
text/javascript
|
||||
text/xml
|
||||
text/plain
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json;
|
||||
|
||||
location /favicon.ico {
|
||||
alias /usr/share/openuds/uds/static/modern/img/favicon.ico;
|
||||
}
|
||||
|
||||
location /uds/res/ {
|
||||
autoindex off;
|
||||
alias /usr/share/openuds/uds/static/;
|
||||
}
|
||||
location / {
|
||||
# First attempt to server /maintenance (to allow easy backend maintenance) if exists
|
||||
# if not, fallback to UDS
|
||||
try_files /maintenance.html @proxy_to_uds;
|
||||
}
|
||||
|
||||
location @proxy_to_uds {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $thescheme;
|
||||
proxy_set_header Host $http_host;
|
||||
# we don't want nginx trying to do something clever with
|
||||
# redirects, we set the Host: header above already.
|
||||
proxy_redirect off;
|
||||
proxy_pass http://uds_server;
|
||||
}
|
||||
}
|
@@ -1,218 +0,0 @@
|
||||
%add_python3_lib_path %_datadir/openuds
|
||||
%allow_python3_import_path %_datadir/openuds
|
||||
%add_findreq_skiplist %_datadir/openuds/uds/transports/*/scripts/windows/* %_datadir/openuds/uds/transports/*/scripts/macosx/*
|
||||
%add_python3_req_skip uds.forward
|
||||
%add_python3_req_skip uds.tunnel
|
||||
%filter_from_provides /^python3(manage)/d
|
||||
%filter_from_provides /^python3(server)/d
|
||||
%filter_from_provides /^python3(server\.settings)/d
|
||||
%filter_from_provides /^python3(server\.urls)/d
|
||||
%filter_from_provides /^python3(server\.wsgi)/d
|
||||
|
||||
Name: openuds-server
|
||||
Version: 3.5.0
|
||||
Release: alt2
|
||||
Summary: Universal Desktop Services (UDS) Broker
|
||||
License: BSD-3-Clause and MIT and Apache-2.0
|
||||
Group: Networking/Remote access
|
||||
URL: https://github.com/dkmstr/openuds
|
||||
AutoReqProv: yes, nopython
|
||||
Source0: %name-%version.tar
|
||||
|
||||
Source10: openuds-httpd.conf
|
||||
Source11: openuds-httpd-ssl.conf
|
||||
Source12: openuds.logrotate
|
||||
Source13: openuds-nginx-sites.conf
|
||||
Source15: openuds-taskmanager.service
|
||||
Source16: openuds-web.service
|
||||
Source17: openuds-web.socket
|
||||
|
||||
#Patch: %name-%version.patch
|
||||
BuildRequires(pre): rpm-macros-systemd
|
||||
Requires: python3-module-django >= 2.2
|
||||
Requires: python3-module-django-dbbackend-mysql >= 2.2
|
||||
Requires: python3-module-django-dbbackend-sqlite3 >= 2.2
|
||||
Requires: openssl
|
||||
Requires: logrotate
|
||||
Requires: openuds-installers
|
||||
|
||||
Conflicts: openuds-tunnel openuds-guacamole-tunnel
|
||||
BuildArch: noarch
|
||||
BuildRequires(pre): rpm-build-python3
|
||||
BuildRequires(pre): webserver-common rpm-build-webserver-common rpm-macros-apache2
|
||||
BuildRequires: python3-module-django
|
||||
|
||||
%description
|
||||
OpenUDS (Universal Desktop Services) is a multiplatform connection broker for:
|
||||
- VDI: Windows and Linux virtual desktops administration and deployment
|
||||
- App virtualization
|
||||
- Desktop services consolidation
|
||||
|
||||
This package provides the required components
|
||||
to allow this machine to work as UDS Broker.
|
||||
|
||||
|
||||
%package apache2
|
||||
Group: Networking/WWW
|
||||
BuildArch: noarch
|
||||
Summary: apache2 configs for %name
|
||||
Requires: %name = %version-%release
|
||||
Requires: apache2-httpd-prefork-like
|
||||
Requires: apache2-base
|
||||
Requires: apache2-mod_wsgi-py3
|
||||
|
||||
%description apache2
|
||||
%summary
|
||||
|
||||
%package nginx
|
||||
Group: Networking/WWW
|
||||
BuildArch: noarch
|
||||
Summary: nginx configs for %name
|
||||
Requires: %name = %version-%release
|
||||
Requires: nginx
|
||||
Requires: python3-module-gunicorn
|
||||
Requires: cert-sh-functions
|
||||
|
||||
%description nginx
|
||||
%summary
|
||||
|
||||
%prep
|
||||
%setup
|
||||
#%patch -p1
|
||||
|
||||
sed -i 's|#!/usr/bin/env python3|#!/usr/bin/python3|' \
|
||||
$(find . -name '*.py')
|
||||
|
||||
%build
|
||||
# Compile localization files
|
||||
django-admin compilemessages
|
||||
#find src/uds/locale -name \*.po -delete
|
||||
|
||||
%install
|
||||
|
||||
mkdir -p %buildroot{%_datadir,%_logdir,%_sysconfdir,%_sharedstatedir}/openuds
|
||||
cp -r src/* %buildroot%_datadir/openuds/
|
||||
mkdir -p %buildroot%_datadir/openuds/uds/static/clients
|
||||
mkdir -p %buildroot%_datadir/openuds/uds/osmanagers/WindowsOsManager/files
|
||||
mv %buildroot%_datadir/openuds/server/settings.py.sample %buildroot%_sysconfdir/openuds/settings.py
|
||||
ln -r -s %buildroot%_logdir/openuds %buildroot%_datadir/openuds/log
|
||||
ln -r -s %buildroot%_sysconfdir/openuds/settings.py %buildroot%_datadir/openuds/server/settings.py
|
||||
# drop httpd-conf snippet
|
||||
install -p -D -m 644 %SOURCE10 %buildroot%apache2_sites_available/openuds.conf
|
||||
install -p -D -m 644 %SOURCE11 %buildroot%apache2_sites_available/openuds-ssl.conf
|
||||
mkdir -p %buildroot%apache2_sites_enabled
|
||||
touch %buildroot%apache2_sites_enabled/openuds.conf
|
||||
install -p -D -m 644 %SOURCE12 %buildroot%_logrotatedir/openuds-server
|
||||
install -p -D -m 644 %SOURCE13 %buildroot%_sysconfdir/nginx/sites-available.d/openuds.conf
|
||||
mkdir -p %buildroot%_sysconfdir/nginx/sites-enabled.d
|
||||
touch %buildroot%_sysconfdir/nginx/sites-enabled.d/openuds.conf
|
||||
install -p -D -m 644 %SOURCE15 %buildroot%_unitdir/openuds-taskmanager.service
|
||||
install -p -D -m 644 %SOURCE16 %buildroot%_unitdir/openuds-web.service
|
||||
install -p -D -m 644 %SOURCE17 %buildroot%_unitdir/openuds-web.socket
|
||||
|
||||
%pre
|
||||
%_sbindir/groupadd -r -f openuds >/dev/null 2>&1 ||:
|
||||
%_sbindir/useradd -M -r -g openuds -G _webserver -c 'OpenUDS Brocker Daemon' \
|
||||
-s /bin/false -d %_sharedstatedir/openuds openuds >/dev/null 2>&1 ||:
|
||||
|
||||
%post
|
||||
if [ $1 -eq 1 ]; then
|
||||
# ugly hack to set a unique SECRET_KEY
|
||||
sed -i "/^SECRET_KEY.*$/{N;s/^.*$/SECRET_KEY='`openssl rand -hex 10`'/}" %_sysconfdir/openuds/settings.py
|
||||
fi
|
||||
|
||||
%post_systemd_postponed openuds-taskmanager.service
|
||||
|
||||
%preun
|
||||
%preun_systemd openuds-taskmanager.service
|
||||
|
||||
%post nginx
|
||||
%post_systemd_postponed openuds-web.socket openuds-web.service
|
||||
# Create SSL certificate for HTTPS server
|
||||
cert-sh generate nginx-openuds ||:
|
||||
|
||||
%preun nginx
|
||||
%preun_systemd openuds-web.service openuds-web.socket
|
||||
|
||||
%files
|
||||
%_datadir/openuds
|
||||
%dir %attr(0750, root, openuds) %_sysconfdir/openuds
|
||||
%config(noreplace) %attr(0640, root, openuds) %_sysconfdir/openuds/settings.py
|
||||
%dir %attr(0770, root, openuds) %_sharedstatedir/openuds
|
||||
%dir %attr(0770, root, openuds) %_logdir/openuds
|
||||
%config(noreplace) %_logrotatedir/openuds-server
|
||||
%_unitdir/openuds-taskmanager.service
|
||||
|
||||
%files apache2
|
||||
%config(noreplace) %apache2_sites_available/*.conf
|
||||
%ghost %apache2_sites_enabled/*.conf
|
||||
|
||||
%files nginx
|
||||
%config(noreplace) %_sysconfdir/nginx/sites-available.d/openuds.conf
|
||||
%ghost %_sysconfdir/nginx/sites-enabled.d/openuds.conf
|
||||
%_unitdir/openuds-web.service
|
||||
%_unitdir/openuds-web.socket
|
||||
|
||||
%changelog
|
||||
* Tue Oct 04 2022 Alexey Shabalin <shaba@altlinux.org> 3.5.0-alt2
|
||||
- Build release-v3.5
|
||||
|
||||
* Mon Aug 22 2022 Alexey Shabalin <shaba@altlinux.org> 3.5.0-alt1
|
||||
- v3.5 snapshot 83394f0d34daf18722923be8d57b35627b330121
|
||||
|
||||
* Mon Nov 29 2021 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt13
|
||||
- Add link for download python 3.9 client.
|
||||
|
||||
* Thu Oct 28 2021 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt12
|
||||
- Switch to use macros from rpm-build-systemd for post scripts.
|
||||
|
||||
* Wed Oct 27 2021 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt11
|
||||
- Add requires openuds-installers (client and actor windows installers).
|
||||
- Revert "Remove download pages".
|
||||
- Fix client and actor file name on download page.
|
||||
|
||||
* Mon Sep 06 2021 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt10
|
||||
- Updated RSA key to 4096 bit in config.
|
||||
|
||||
* Wed Aug 18 2021 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt9
|
||||
- v3.0 snapshot 51b0cec5365698dffdb9a3a468d52bbba4656ba4
|
||||
|
||||
* Fri Jul 09 2021 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt8
|
||||
- Fix Russian translation
|
||||
- Update SECRET_KEY config for install only in %%post
|
||||
|
||||
* Wed Jun 23 2021 Andrey Cherepanov <cas@altlinux.org> 3.0.0-alt7.2
|
||||
- Compile l10n messages using django-admin
|
||||
- Add Russian language to server config file
|
||||
|
||||
* Sat Jun 05 2021 Andrey Cherepanov <cas@altlinux.org> 3.0.0-alt7.1
|
||||
- NMU: package compiled localization files (ALT #40161)
|
||||
|
||||
* Fri Apr 23 2021 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt7
|
||||
- Fix create home dir for user openuds
|
||||
|
||||
* Thu Apr 22 2021 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt6
|
||||
- Switch to local memory from memcached by default in settings.py.
|
||||
- Fix openuds-web.service for execute gunicorn.py3 for use python3.
|
||||
- Add conflicts with openuds-tunnel,openuds-guacamole-tunnel.
|
||||
|
||||
* Wed Apr 21 2021 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt5
|
||||
- Fix typo in nginx config (ALT #39968)
|
||||
|
||||
* Wed Apr 14 2021 Mikhail Gordeev <obirvalger@altlinux.org> 3.0.0-alt4
|
||||
- Remove pages and buttons with downloading clients and actors
|
||||
|
||||
* Mon Dec 07 2020 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt3
|
||||
- merge with upstream v3.0 branch (b1c43850908c5c207afa5812edc6c1ce46d8ca78)
|
||||
- update nginx config
|
||||
|
||||
* Thu Dec 03 2020 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt2
|
||||
- move apache config to apache2 package
|
||||
- add package with nginx config and service for start django app over gunicorn
|
||||
|
||||
* Thu Nov 05 2020 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt1
|
||||
- 3.0.0 Release
|
||||
|
||||
* Tue Apr 14 2020 Alexey Shabalin <shaba@altlinux.org> 3.0.0-alt0.1.git.d7e30d14
|
||||
- Initial build for ALT
|
||||
|
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=OpenUDS Broker task manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=openuds
|
||||
Group=openuds
|
||||
RuntimeDirectory=openuds
|
||||
WorkingDirectory=/usr/share/openuds
|
||||
ExecStart=/usr/bin/python3 /usr/share/openuds/manage.py taskManager --start --foreground
|
||||
PrivateTmp=true
|
||||
Restart=always
|
||||
RestartSec=16
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@@ -1,21 +0,0 @@
|
||||
[Unit]
|
||||
Description=OpenUDS Broker Web server daemon
|
||||
Requires=openuds-web.socket
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
PIDFile=/run/openuds/pid
|
||||
User=openuds
|
||||
Group=openuds
|
||||
RuntimeDirectory=openuds
|
||||
WorkingDirectory=/usr/share/openuds
|
||||
ExecStart=/usr/bin/gunicorn.py3 --pid /run/openuds/pid \
|
||||
--bind unix:/run/openuds/socket server.wsgi \
|
||||
--workers 5 --threads 8
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
ExecStop=/bin/kill -s TERM $MAINPID
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Also=openuds-web.socket
|
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=OpenUDS Broker Web server socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/openuds/socket
|
||||
SocketUser=openuds
|
||||
SocketGroup=_webserver
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
@@ -1,8 +0,0 @@
|
||||
/var/log/openuds/*.log {
|
||||
weekly
|
||||
rotate 4
|
||||
missingok
|
||||
compress
|
||||
minsize 100k
|
||||
}
|
||||
|
@@ -1,106 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
# Copyright (C) 2022
|
||||
# Alexander Burmatov
|
||||
# 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 the Alexander Burmatov 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
|
||||
# OWNER 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: Alexander Burmatov, thatman at altlinux dot org
|
||||
'''
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import os
|
||||
import sys
|
||||
import secrets
|
||||
import MySQLdb
|
||||
import datetime
|
||||
|
||||
sys.path.append('/etc/openuds/')
|
||||
from settings import DATABASES
|
||||
|
||||
|
||||
ip_addr = socket.gethostbyname(socket.gethostname())
|
||||
creation_datetime = datetime.datetime.today()
|
||||
|
||||
parser = argparse.ArgumentParser(description='Register tunnel token in MySQL DB')
|
||||
parser.add_argument(
|
||||
'-H',
|
||||
'--host',
|
||||
type=str,
|
||||
default='',
|
||||
help='Input tunnel server IP Address'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n',
|
||||
'--name',
|
||||
type=str,
|
||||
default='',
|
||||
help='Input tunnel server name'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-t',
|
||||
'--token',
|
||||
type=str,
|
||||
default='',
|
||||
help='Input tunnel server token (default: "")'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-N',
|
||||
'--generate_new_token',
|
||||
type=bool,
|
||||
default=False,
|
||||
help='Input True if you want to generate a new token (default: False)'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
empty_name = args.name == ''
|
||||
empty_ip = args.host == ''
|
||||
only_token = args.token != '' and not args.generate_new_token
|
||||
only_gen_new_token = args.token == '' and args.generate_new_token
|
||||
if empty_ip:
|
||||
print('Empty tunnel server IP Address')
|
||||
elif empty_name:
|
||||
print('Empty tunnel server name')
|
||||
elif args.token == '' and not args.generate_new_token:
|
||||
print('Choose to generate a new token or enter a token')
|
||||
elif only_token != only_gen_new_token:
|
||||
if only_gen_new_token:
|
||||
token = secrets.token_urlsafe(36)
|
||||
else:
|
||||
token = args.token
|
||||
db=MySQLdb.connect(host=DATABASES['default']['HOST'], user=DATABASES['default']['USER'],
|
||||
passwd=DATABASES['default']['PASSWORD'], db=DATABASES['default']['NAME'])
|
||||
c=db.cursor()
|
||||
c.execute("""INSERT INTO uds_tunneltoken(username, ip_from, ip, hostname, token, stamp) VALUES (%s,%s,%s,%s,%s,%s);""",
|
||||
(os.getlogin(), ip_addr, args.host, args.name, token, creation_datetime,))
|
||||
db.commit()
|
||||
c.close()
|
||||
print(f'Tunnel token register success. (With token: {token})')
|
||||
else:
|
||||
print('Choose to generate a new token only or only enter the token')
|
@@ -1,7 +0,0 @@
|
||||
tar: server name=@name@-@version@ base=@name@-@version@
|
||||
spec: .gear/openuds-server.spec
|
||||
copy?: .gear/*.logrotate
|
||||
copy?: .gear/*.conf
|
||||
copy?: .gear/*.service
|
||||
copy?: .gear/*.socket
|
||||
copy?: .gear/*.patch
|
@@ -1,4 +0,0 @@
|
||||
Linux:
|
||||
python3-prctl (recommended, but not required in fact)
|
||||
python3-pyqt5
|
||||
|
@@ -11,6 +11,9 @@ dpkg-buildpackage -b
|
||||
cat udsactor-template.spec |
|
||||
sed -e s/"version 0.0.0"/"version ${VERSION}"/g |
|
||||
sed -e s/"release 1"/"release ${RELEASE}"/g > udsactor-$VERSION.spec
|
||||
cat udsactor-unmanaged-template.spec |
|
||||
sed -e s/"version 0.0.0"/"version ${VERSION}"/g |
|
||||
sed -e s/"release 1"/"release ${RELEASE}"/g > udsactor-unmanaged-$VERSION.spec
|
||||
|
||||
# Now fix dependencies for opensuse
|
||||
# Note that, although on opensuse the library is "libXss1" on newer,
|
||||
@@ -22,7 +25,7 @@ cat udsactor-template.spec |
|
||||
# sed -e s/"libXScrnSaver"/"libXss1"/g > udsactor-opensuse-$VERSION.spec
|
||||
|
||||
#for pkg in udsactor-$VERSION.spec udsactor-opensuse-$VERSION.spec; do
|
||||
for pkg in udsactor-$VERSION.spec; do
|
||||
for pkg in udsactor-*$VERSION.spec; do
|
||||
|
||||
rm -rf rpm
|
||||
for folder in SOURCES BUILD RPMS SPECS SRPMS; do
|
||||
|
@@ -1,3 +1,9 @@
|
||||
udsactor (3.6.0) stable; urgency=medium
|
||||
|
||||
* Upgraded to 3.6.0 release
|
||||
|
||||
-- Adolfo Gómez García <agomez@virtualcable.es> Fri, 1 Jul 2022 14:00:00 +0200
|
||||
|
||||
udsactor (3.5.0) stable; urgency=medium
|
||||
|
||||
* Upgraded to 3.5.0 release
|
||||
|
@@ -1,3 +1,3 @@
|
||||
udsactor-unmanaged_3.5.0_all.deb admin optional
|
||||
udsactor_3.5.0_all.deb admin optional
|
||||
udsactor_3.5.0_amd64.buildinfo admin optional
|
||||
udsactor-unmanaged_3.6.0_all.deb admin optional
|
||||
udsactor_3.6.0_all.deb admin optional
|
||||
udsactor_3.6.0_amd64.buildinfo admin optional
|
||||
|
70
actor/linux/udsactor-unmanaged-template.spec
Normal file
70
actor/linux/udsactor-unmanaged-template.spec
Normal file
@@ -0,0 +1,70 @@
|
||||
%define _topdir %(echo $PWD)/rpm
|
||||
%define name udsactor-unmanaged
|
||||
%define version 0.0.0
|
||||
%define release 1
|
||||
%define buildroot %{_topdir}/%{name}-%{version}-%{release}-root
|
||||
|
||||
BuildRoot: %{buildroot}
|
||||
Name: %{name}
|
||||
Version: %{version}
|
||||
Release: %{release}
|
||||
Summary: Actor for Universal Desktop Services (UDS) Broker
|
||||
License: BSD3
|
||||
Group: Admin
|
||||
Requires: python3-six python3-requests python3-qt5 libXScrnSaver
|
||||
Vendor: Virtual Cable S.L.U.
|
||||
URL: http://www.udsenterprise.com
|
||||
Provides: udsactor
|
||||
|
||||
%define _rpmdir ../
|
||||
%define _rpmfilename %%{NAME}-%%{VERSION}-%%{RELEASE}.%%{ARCH}.rpm
|
||||
|
||||
|
||||
%install
|
||||
curdir=`pwd`
|
||||
cd ../..
|
||||
make DESTDIR=$RPM_BUILD_ROOT DISTRO=rh install-udsactor-unmanaged
|
||||
cd $curdir
|
||||
|
||||
%clean
|
||||
rm -rf $RPM_BUILD_ROOT
|
||||
curdir=`pwd`
|
||||
cd ../..
|
||||
make DESTDIR=$RPM_BUILD_ROOT DISTRO=rh clean
|
||||
cd $curdir
|
||||
|
||||
|
||||
%post
|
||||
systemctl enable udsactor.service > /dev/null 2>&1
|
||||
|
||||
%preun
|
||||
systemctl disable udsactor.service > /dev/null 2>&1
|
||||
systemctl stop udsactor.service > /dev/null 2>&1
|
||||
|
||||
%postun
|
||||
# $1 == 0 on uninstall, == 1 on upgrade for preun and postun (just a reminder for me... :) )
|
||||
if [ $1 -eq 0 ]; then
|
||||
rm -rf /etc/udsactor
|
||||
rm /var/log/udsactor.log
|
||||
fi
|
||||
# And, posibly, the .pyc leaved behind on /usr/share/UDSActor
|
||||
rm -rf /usr/share/UDSActor > /dev/null 2>&1
|
||||
|
||||
%description
|
||||
This package provides the required components to allow this unmanaged machine to work on an environment managed by UDS Broker.
|
||||
|
||||
%files
|
||||
%defattr(-,root,root)
|
||||
/etc/udsactor
|
||||
/etc/xdg/autostart/UDSActorTool.desktop
|
||||
/etc/systemd/system/udsactor.service
|
||||
/usr/bin/UDSActorTool-startup
|
||||
/usr/bin/udsactor
|
||||
/usr/bin/udsvapp
|
||||
/usr/bin/UDSActorTool
|
||||
/usr/sbin/UDSActorConfig
|
||||
/usr/sbin/UDSActorConfig-pkexec
|
||||
/usr/share/UDSActor/*
|
||||
/usr/share/applications/UDS_Actor_Configuration.desktop
|
||||
/usr/share/autostart/UDSActorTool.desktop
|
||||
/usr/share/polkit-1/actions/org.openuds.pkexec.UDSActorConfig.policy
|
@@ -214,10 +214,10 @@
|
||||
<item row="2" column="1">
|
||||
<widget class="QLineEdit" name="serviceToken">
|
||||
<property name="toolTip">
|
||||
<string>UDS user with administration rights (Will not be stored on template)</string>
|
||||
<string>UDS Service Token</string>
|
||||
</property>
|
||||
<property name="whatsThis">
|
||||
<string><html><head/><body><p>Administrator user on UDS Server.</p><p>Note: This credential will not be stored on client. Will be used to obtain an unique token for this image.</p></body></html></string>
|
||||
<string><html><head/><body><p>Token of the service on UDS platform</p><p>This token can be obtainend from the service configuration on UDS.</p></body></html></string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@@ -268,10 +268,10 @@
|
||||
<item row="3" column="1">
|
||||
<widget class="QLineEdit" name="restrictNet">
|
||||
<property name="toolTip">
|
||||
<string>UDS user with administration rights (Will not be stored on template)</string>
|
||||
<string>Restrict valid detection of network interfaces to this network.</string>
|
||||
</property>
|
||||
<property name="whatsThis">
|
||||
<string><html><head/><body><p>Administrator user on UDS Server.</p><p>Note: This credential will not be stored on client. Will be used to obtain an unique token for this image.</p></body></html></string>
|
||||
<string><html><head/><body><p>Restrics valid detection of network interfaces.</p><p>Note: Use this field only in case of several network interfaces, so UDS knows which one is the interface where the user will be connected..</p></body></html></string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
|
@@ -35,4 +35,4 @@ from . import platform
|
||||
__title__ = 'udsactor'
|
||||
__author__ = 'Adolfo Gómez <dkmaster@dkmon.com>'
|
||||
__license__ = "BSD 3-clause"
|
||||
__copyright__ = "Copyright 2014-2020 VirtualCable S.L.U."
|
||||
__copyright__ = "Copyright 2014-2022 VirtualCable S.L.U."
|
||||
|
@@ -71,7 +71,7 @@ class HTTPServerHandler(http.server.BaseHTTPRequestHandler):
|
||||
# Very simple path & params splitter
|
||||
path = self.path.split('?')[0][1:].split('/')
|
||||
|
||||
logger.debug('Path: %s, params: %s', path, params)
|
||||
logger.debug('Path: %s, ip: %s, params: %s', path, self.client_address, params)
|
||||
|
||||
handlerType: typing.Optional[typing.Type['Handler']] = None
|
||||
|
||||
|
@@ -1 +1 @@
|
||||
VERSION = '3.5.0'
|
||||
VERSION = '3.6.0'
|
||||
|
@@ -146,7 +146,7 @@ class Ui_UdsActorSetupDialog(object):
|
||||
self.host.setToolTip(_translate("UdsActorSetupDialog", "Uds Broker Server Addres. Use IP or FQDN"))
|
||||
self.host.setWhatsThis(_translate("UdsActorSetupDialog", "Enter here the UDS Broker Addres using either its IP address or its FQDN address"))
|
||||
self.label_serviceToken.setText(_translate("UdsActorSetupDialog", "Service Token"))
|
||||
self.serviceToken.setToolTip(_translate("UdsActorSetupDialog", "UDS user with administration rights (Will not be stored on template)"))
|
||||
self.serviceToken.setToolTip(_translate("UdsActorSetupDialog", "UDS Service Token"))
|
||||
self.serviceToken.setWhatsThis(_translate("UdsActorSetupDialog", "<html><head/><body><p>Administrator user on UDS Server.</p><p>Note: This credential will not be stored on client. Will be used to obtain an unique token for this image.</p></body></html>"))
|
||||
self.label_loglevel.setText(_translate("UdsActorSetupDialog", "Log Level"))
|
||||
self.label_restrictNet.setText(_translate("UdsActorSetupDialog", "Restrict Net"))
|
||||
|
@@ -1,3 +1,9 @@
|
||||
udsclient3 (3.6.0) stable; urgency=medium
|
||||
|
||||
* Upgraded to 3.6.0 release
|
||||
|
||||
-- Adolfo Gómez García <agomez@virtualcable.es> Fri, 1 Jul 2022 14:12:10 +0200
|
||||
|
||||
udsclient3 (3.5.0) stable; urgency=medium
|
||||
|
||||
* Upgraded to 3.5.0 release
|
||||
|
@@ -1 +1 @@
|
||||
9
|
||||
10
|
||||
|
@@ -1,2 +1,2 @@
|
||||
udsclient3_3.5.0_all.deb admin optional
|
||||
udsclient3_3.5.0_amd64.buildinfo admin optional
|
||||
udsclient3_3.6.0_all.deb admin optional
|
||||
udsclient3_3.6.0_amd64.buildinfo admin optional
|
||||
|
@@ -29,13 +29,11 @@
|
||||
'''
|
||||
@author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
'''
|
||||
from __future__ import unicode_literals
|
||||
|
||||
VERSION = '3.5.0'
|
||||
VERSION = '3.6.0'
|
||||
|
||||
__title__ = 'udclient'
|
||||
__version__ = VERSION
|
||||
__build__ = 0x010760
|
||||
__author__ = 'Adolfo Gómez'
|
||||
__build__ = 0x010712
|
||||
__author__ = 'Adolfo Gómez <dkmaster@dkmon.com>'
|
||||
__license__ = "BSD 3-clause"
|
||||
__copyright__ = "Copyright 2014-2017 VirtualCable S.L.U."
|
||||
__copyright__ = "Copyright 2014-2022 VirtualCable S.L.U."
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2021 Virtual Cable S.L.U.
|
||||
# Copyright (c) 2022 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
|
@@ -59,9 +59,8 @@ LANGUAGE_CODE = 'en'
|
||||
ugettext = lambda s: s
|
||||
|
||||
LANGUAGES = (
|
||||
('ru', ugettext('Russian')),
|
||||
('en', ugettext('English')),
|
||||
('es', ugettext('Spanish')),
|
||||
('en', ugettext('English')),
|
||||
('fr', ugettext('French')),
|
||||
('de', ugettext('German')),
|
||||
('pt', ugettext('Portuguese')),
|
||||
@@ -131,13 +130,13 @@ CACHES = {
|
||||
'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.MemcachedCache',
|
||||
# 'LOCATION': '127.0.0.1:11211',
|
||||
#},
|
||||
# 'memory': {
|
||||
# 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
# }
|
||||
'memory': {
|
||||
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
|
||||
'LOCATION': '127.0.0.1:11211',
|
||||
},
|
||||
}
|
||||
|
||||
# Related to file uploading
|
||||
@@ -148,58 +147,7 @@ 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*'
|
||||
# This is a very long string, an RSA KEY (this can be changed, but if u loose it, all encription will be lost)
|
||||
RSA_KEY = '''-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIJKAIBAAKCAgEAu6Zr65Q6Y81W1j/tD0GrpqOmwiLhifbeY/xYbUgq/4555wrR
|
||||
nb2Z2oynF6aaSbWaaCsCJSoziGAItYUaY58vlx1RPnrr3Kb/c9zsCnWINy++LkrZ
|
||||
NzwrVzo1XyzKjMlCwXRYOCrXdyzuRhhw32gzARzHbBWtHvMQ8vOxWDM1yjkSFZ+i
|
||||
CDEXruKQ8HrcZvGvKoqMWZeihi+g+RlZvaWimM9wkbfzXsTljIwYwb8YhNLfzad6
|
||||
VEymnBeX6COlig2hOup+7HHcuuYAgURm0cvkwPKwBt9tbZ0C3hB04HH9bIJ92DLI
|
||||
eI0ljICHI2BNyafQO74MngAJYc2O0Kak4MSIeqCVf/i5W77rSsDF3rzsNzCnD2lw
|
||||
S8hIl0pp8n8kNLalyAIidnxivLFnCiz47F+We1HAVx2WcQ7x6BUQjoUPV2Ao9Nk2
|
||||
pwdXGjyHAq5cbDKQNd+Z09ohYnRnLQ7bPQqsIPUuAr/xsp1LugOQxXRrsGNKIhb9
|
||||
gIY6Xuk19VJN2HDCPb3E79fE0MRjFrPbD57tWxjZvaJ589f4HybSAFr5EDWcoq+K
|
||||
jCCEp3rl1iRNmBt6ga7MMpuWOleOIFgzas4Pqcir5Ir9JuCnTBl4WS9536p1rSRR
|
||||
KgwgWJuCmereRqznV79PXBmjWP7PzZHQKEgqQTLGx8yzSdlfr+Xn5/QY6CsCAwEA
|
||||
AQKCAgBajzpN+r8jIxnhVACH+F1qklgMIOFPv8Ab6NCUDNwTcSMLiYEX60Sw4GhT
|
||||
VafoIqhd1UO3w+IS0qXhVFcj9NL2zsNCr/fcCQwHUnrnoUkXuQbDOIQT0AnqksDn
|
||||
Kaqmvwpkak2Y7bQGY7yqP2lZp+PViZoEY4GlChEH7uuLcUtoSJqdrOh5o5eWYukn
|
||||
5WMwmzq6ARsysadRsvKwxLc2exU5QgNFj8z303YkmgfomNywXUR873Jt9IADnK9G
|
||||
7M0bDvDcigau/VRVLmLX/7bPUOMY6wZgirs4xyl3GeIN33U6RM+O4xM0eonAgNa3
|
||||
D+b6hu5LprBYIBRnCtj9gS7Y+oASONDUA2/dA81SWGxUGwtRhSMXlypM4qILMm+I
|
||||
UwpJdbnbPFo31N10B9Y/VDk2cfdnuDbkQif8DkyHxGEmyEOVWaI4Uibwg5TNX2+N
|
||||
fQOZveEXbLlsiTLMa8vxc07w/nUJ7TbZ2vxGzO5XVZmZbwoB9XE5VSTVaV660P6q
|
||||
B5T6m/KRMCcAX5mWstyWdjjbGB/IS3O7jxjl2EzbNp8SES8WY/UjEc82rwq9cjon
|
||||
3sibMwwJf1j5HSvqOBDfU/uWWleAkSgENOeC0oym38TxeWY3YD19f88KDeKNa/qJ
|
||||
ko/2/tHrworxYHk6X0/XHBF9Ylwm6FHRCcrjhGRpoOpq99WjYQKCAQEA+YDUUU1h
|
||||
3aD1jvkFAhVoIL/fi2yqiUx8O0jP2f6k5RV8AaiTqZkZQs9LUy3xfZkbXf/TeUar
|
||||
ONYd0iPiPM5cyx1tZW6rO32HY3P9AZSRpB1KlEHrFqVq5N9zuyUbiQU7iLUQ1qVG
|
||||
+5eafPfifCi4pZQ14+LuxvLCiCOaGBa/XidjR82yAjZKESwJsTyS1AmLwlHQuAXr
|
||||
3TxBh8/21+RXPnV4sqCuHFiYBQYyZIODRxxoo0Maz7dXP4F/7LaJ4rNAr5VZu4xe
|
||||
UgFH5Y1lhI8orUlMN5QKUcbAdQb2+/DEz8uiiRaxdGA2fZiP366VQf2Hjg/syM9S
|
||||
ePfZEAF0EFZqtwKCAQEAwIlIlIPVdZZuqR4s+X32p50i5f7vVI5eCryNTe+eY0uW
|
||||
IDm/N4AKJtxTTw5E1klyFgR36cHI1DGQCpUJczK4XNfXMTiqBomg7gOrFMwSns70
|
||||
YJuZGLRJ8AJCJ9aaGN2Tp7cEnCkl6mEbpB3tz4lg1tdgmxyOVG/BjKuy4mskM+DX
|
||||
hmELFFGTQimamgyGLHs7F8DBBd9RoECc32LWQhebgrs6o09EHPX85qTLd6alQ2y6
|
||||
QIQBkZ4Cf8pjtQ5DEaWv5ll8TFnUroLYGk8+Z94tylXe/OPlUD9oT1efElXHawGo
|
||||
B+a4/jYg+otmZMd1h8wakxA0DaszLntYhklMeCsKLQKCAQEAkE2CfsNSpuxBGb/M
|
||||
tbfL0aUnlWvz9hTWhTNHg71TgWs7nXnybVSu14Us+57G3O2Ado1PsgS9YtHzzWYT
|
||||
ozd3U8JhUxj+0Bl6BHtBCXj0Awct6zF01lQ1zsmuFb8Qfd8Y36hZZMuSklDMeRql
|
||||
U0n6AUoHIRZPI6GkATkWFniRldMSCKVfHwhnLidaM7fk893Rh0HqhYRnNj1zaSlO
|
||||
iu71xpoKhMWJ7bsPsVg0LQ4jDy3PFx3ok9pmC8TKSA4LEaK69tJaY657ntI+0vVE
|
||||
kbZ6wD50ZnCox0M8bHLqxiwqqEQObVtCpxw+Z8Wk8Kn4iYIotHFhcYL3IB+42xj8
|
||||
F7bjYQKCAQAtFXhrXltzh2AuNaSuXzKMmRz84i9Ei3m15eTopP1fnulublc6Nb8q
|
||||
zliroFm6G8SdJzq0/+140oo6ECAAW5YUF26KVgxqL3wBf9ZlrkuF6EwM+yJkSIMv
|
||||
sjevgG7g97GFijOIJZJ9SXPhgCiZej+0zwYODCe/2dNmjyX6IsB9bV7KVprzjQ6A
|
||||
ZaGQBPK+I7T4oOgR6fMBJWAWZtxo6YI+oHzglMUMSwWHNHt4bwsvuJv8U2zSnj9T
|
||||
kR673LUTwspnk/ipIDfPDVBxCLFCPSJXyLfIW8zsd4yDV68l6fQiOGcSQpJ65E3g
|
||||
nRfC7Xm17LMkUJz/vmDjt3pJJ4zCbsGZAoIBABfQCRUEqniropccsF09FiXDvKF8
|
||||
e5jN1HybXltnjlGlas4IJdVaGMPdJC3zZGpdnbGK8y8YsXwWlEgTQuUqBgjRdmt3
|
||||
toRDTVSKc+4g2P7Vv2hG2Q2ofayXiyez1h9wwcdUyn+oeSvOTJuxL4P4Gh0psfsS
|
||||
jyoGbNL4P5ONJaknVxsv/zcbHW3BrawbpBo4Pao7hWCnyJd5f33uOW/9eAfa5gXq
|
||||
fsHnIw6EsZLMJmZgQcx6be4ZkMX00FR4+uxqouVbgdWIhjDxTdFXD4mPoa1XExTd
|
||||
7NIOhKdva4tz0Re3b5qWwNnsHLitx+YJor1JjlZ8wwlZ7LC2d/wcxQtQIpI=
|
||||
-----END RSA PRIVATE KEY-----'''
|
||||
|
||||
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-----'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
|
@@ -43,6 +43,8 @@ from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext as _
|
||||
from uds.core import VERSION, VERSION_STAMP
|
||||
|
||||
from . import log
|
||||
|
||||
from .handlers import (
|
||||
Handler,
|
||||
HandlerError,
|
||||
@@ -61,8 +63,6 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ['Handler', 'Dispatcher']
|
||||
|
||||
AUTH_TOKEN_HEADER = 'X-Auth-Token'
|
||||
|
||||
|
||||
@@ -82,9 +82,6 @@ class Dispatcher(View):
|
||||
"""
|
||||
Processes the REST request and routes it wherever it needs to be routed
|
||||
"""
|
||||
# Remove session from request, so response middleware do nothing with this
|
||||
del request.session
|
||||
|
||||
# Now we extract method and possible variables from path
|
||||
path: typing.List[str] = kwargs['arguments'].split('/')
|
||||
del kwargs['arguments']
|
||||
@@ -149,6 +146,8 @@ class Dispatcher(View):
|
||||
except processors.ParametersException as e:
|
||||
logger.debug('Path: %s', full_path)
|
||||
logger.debug('Error: %s', e)
|
||||
log.log_operation(handler, 500, log.ERROR)
|
||||
|
||||
return http.HttpResponseServerError(
|
||||
'Invalid parameters invoking {0}: {1}'.format(full_path, e),
|
||||
content_type="text/plain",
|
||||
@@ -158,6 +157,8 @@ class Dispatcher(View):
|
||||
for n in ['get', 'post', 'put', 'delete']:
|
||||
if hasattr(handler, n):
|
||||
allowedMethods.append(n)
|
||||
log.log_operation(handler, 405, log.ERROR)
|
||||
|
||||
return http.HttpResponseNotAllowed(
|
||||
allowedMethods, content_type="text/plain"
|
||||
)
|
||||
@@ -168,6 +169,8 @@ class Dispatcher(View):
|
||||
except Exception:
|
||||
logger.exception('error accessing attribute')
|
||||
logger.debug('Getting attribute %s for %s', http_method, full_path)
|
||||
|
||||
log.log_operation(handler, 500, log.ERROR)
|
||||
return http.HttpResponseServerError(
|
||||
'Unexcepected error', content_type="text/plain"
|
||||
)
|
||||
@@ -182,20 +185,29 @@ class Dispatcher(View):
|
||||
response['UDS-Version'] = f'{VERSION};{VERSION_STAMP}'
|
||||
for k, val in handler.headers().items():
|
||||
response[k] = val
|
||||
|
||||
log.log_operation(handler, response.status_code, log.INFO)
|
||||
return response
|
||||
except RequestError as e:
|
||||
log.log_operation(handler, 400, log.ERROR)
|
||||
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
|
||||
except ResponseError as e:
|
||||
log.log_operation(handler, 500, log.ERROR)
|
||||
return http.HttpResponseServerError(str(e), content_type="text/plain")
|
||||
except NotSupportedError as e:
|
||||
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
|
||||
log.log_operation(handler, 501, log.ERROR)
|
||||
return http.HttpResponseBadRequest(str(e), content_type="text/plain", status=501)
|
||||
except AccessDenied as e:
|
||||
log.log_operation(handler, 403, log.ERROR)
|
||||
return http.HttpResponseForbidden(str(e), content_type="text/plain")
|
||||
except NotFound as e:
|
||||
log.log_operation(handler, 404, log.ERROR)
|
||||
return http.HttpResponseNotFound(str(e), content_type="text/plain")
|
||||
except HandlerError as e:
|
||||
log.log_operation(handler, 500, log.ERROR)
|
||||
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
|
||||
except Exception as e:
|
||||
log.log_operation(handler, 500, log.ERROR)
|
||||
logger.exception('Error processing request')
|
||||
return http.HttpResponseServerError(str(e), content_type="text/plain")
|
||||
|
||||
@@ -240,7 +252,9 @@ class Dispatcher(View):
|
||||
# Dinamycally import children of this package.
|
||||
package = 'methods'
|
||||
|
||||
pkgpath = os.path.join(os.path.dirname(sys.modules[__name__].__file__), package)
|
||||
pkgpath = os.path.join(
|
||||
os.path.dirname(typing.cast(str, sys.modules[__name__].__file__)), package
|
||||
)
|
||||
for _, name, _ in pkgutil.iter_modules([pkgpath]):
|
||||
# __import__(__name__ + '.' + package + '.' + name, globals(), locals(), [], 0)
|
||||
importlib.import_module(
|
||||
|
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-2021 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
@@ -28,7 +27,7 @@
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
"""
|
||||
@author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import typing
|
||||
import logging
|
||||
@@ -42,6 +41,9 @@ from uds.core.util import net
|
||||
from uds.models import Authenticator, User
|
||||
from uds.core.managers import cryptoManager
|
||||
|
||||
from . import log
|
||||
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core.util.request import ExtendedHttpRequestWithUser
|
||||
@@ -127,8 +129,8 @@ class Handler:
|
||||
self,
|
||||
request: 'ExtendedHttpRequestWithUser',
|
||||
path: str,
|
||||
operation: str,
|
||||
params: typing.Any,
|
||||
method: str,
|
||||
params: typing.MutableMapping[str, typing.Any],
|
||||
*args: str,
|
||||
**kwargs
|
||||
):
|
||||
@@ -147,7 +149,7 @@ class Handler:
|
||||
|
||||
self._request = request
|
||||
self._path = path
|
||||
self._operation = operation
|
||||
self._operation = method
|
||||
self._params = params
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
@@ -178,6 +180,7 @@ class Handler:
|
||||
else:
|
||||
self._user = User() # Empty user for non authenticated handlers
|
||||
|
||||
|
||||
def headers(self) -> typing.Dict[str, str]:
|
||||
"""
|
||||
Returns the headers of the REST request (all)
|
||||
|
107
server/src/uds/REST/log.py
Normal file
107
server/src/uds/REST/log.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# -*- 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.util.log import (
|
||||
REST,
|
||||
OWNER_TYPE_AUDIT,
|
||||
DEBUG,
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR,
|
||||
CRITICAL,
|
||||
)
|
||||
|
||||
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 = (
|
||||
('providers', models.Provider),
|
||||
('services', models.Service),
|
||||
('users', models.User),
|
||||
('groups', models.Group),
|
||||
)
|
||||
|
||||
|
||||
def replacePath(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 # type: ignore
|
||||
path = path.replace(uuid, f'[{name}]')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def log_operation(
|
||||
handler: typing.Optional['Handler'], response_code: int, level: int = INFO
|
||||
):
|
||||
"""
|
||||
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 ('overview', 'tableinfo', 'gui', 'types', 'system')
|
||||
):
|
||||
return
|
||||
|
||||
path = replacePath(path)
|
||||
|
||||
username = handler._request.user.pretty_name if handler._request.user else 'Unknown'
|
||||
# Global log is used without owner nor type
|
||||
models.Log.objects.create(
|
||||
owner_id=0,
|
||||
owner_type=OWNER_TYPE_AUDIT,
|
||||
created=models.getSqlDatetime(),
|
||||
level=level,
|
||||
source=REST,
|
||||
data=f'{handler._request.ip} {username}: [{handler._request.method}/{response_code}] {path}'[
|
||||
:255
|
||||
],
|
||||
)
|
@@ -85,7 +85,7 @@ def checkBlockedIp(ip: str) -> None:
|
||||
|
||||
def incFailedIp(ip: str) -> None:
|
||||
cache = Cache('actorv3')
|
||||
fails = (cache.get(ip) or 0) + 1
|
||||
fails = cache.get(ip, 0) + 1
|
||||
cache.put(ip, fails, GlobalConfig.LOGIN_BLOCK.getInt())
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ class ActorV3Action(Handler):
|
||||
try:
|
||||
return UserService.objects.get(uuid=self._params['token'])
|
||||
except UserService.DoesNotExist:
|
||||
logger.error('User service not found (params: %s)', self._params)
|
||||
raise BlockAccess()
|
||||
|
||||
def action(self) -> typing.MutableMapping[str, typing.Any]:
|
||||
@@ -121,13 +122,13 @@ class ActorV3Action(Handler):
|
||||
|
||||
def post(self) -> typing.MutableMapping[str, typing.Any]:
|
||||
try:
|
||||
checkBlockedIp(self._request.ip) # pylint: disable=protected-access
|
||||
checkBlockedIp(self._request.ip)
|
||||
result = self.action()
|
||||
logger.debug('Action result: %s', result)
|
||||
return result
|
||||
except (BlockAccess, KeyError):
|
||||
# For blocking attacks
|
||||
incFailedIp(self._request.ip) # pylint: disable=protected-access
|
||||
incFailedIp(self._request.ip)
|
||||
except Exception as e:
|
||||
logger.exception('Posting %s: %s', self.__class__, e)
|
||||
|
||||
@@ -182,6 +183,7 @@ class Register(ActorV3Action):
|
||||
actorToken.log_level = self._params['log_level']
|
||||
actorToken.stamp = getSqlDatetime()
|
||||
actorToken.save()
|
||||
logger.info('Registered actor %s', self._params)
|
||||
except Exception:
|
||||
actorToken = ActorToken.objects.create(
|
||||
username=self._user.pretty_name,
|
||||
@@ -454,8 +456,10 @@ class LoginLogout(ActorV3Action):
|
||||
else:
|
||||
service.processLogout(validId, remote_login=is_remote)
|
||||
|
||||
# All right, service notified...
|
||||
except Exception:
|
||||
# All right, service notified..
|
||||
except Exception as e :
|
||||
# Log error and continue
|
||||
logger.error('Error notifying service: %s (%s)', e, self._params)
|
||||
raise BlockAccess()
|
||||
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.
|
||||
# Copyright (c) 2014-2022 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
@@ -34,12 +34,13 @@ import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import ugettext, ugettext_lazy as _
|
||||
from uds.models import Authenticator
|
||||
from uds.models import Authenticator, MFA
|
||||
from uds.core import auths
|
||||
|
||||
from uds.REST import NotFound
|
||||
from uds.REST.model import ModelHandler
|
||||
from uds.core.util import permissions
|
||||
from uds.core.util.model import processUuid
|
||||
from uds.core.ui import gui
|
||||
|
||||
from .users_groups import Users, Groups
|
||||
@@ -58,7 +59,7 @@ class Authenticators(ModelHandler):
|
||||
# Custom get method "search" that requires authenticator id
|
||||
custom_methods = [('search', True)]
|
||||
detail = {'users': Users, 'groups': Groups}
|
||||
save_fields = ['name', 'comments', 'tags', 'priority', 'small_name', 'visible']
|
||||
save_fields = ['name', 'comments', 'tags', 'priority', 'small_name', 'visible', 'mfa_id:'] # mfa_id is optional, and defaults to '' (no mfa)
|
||||
|
||||
table_title = _('Authenticators')
|
||||
table_fields = [
|
||||
@@ -70,6 +71,7 @@ class Authenticators(ModelHandler):
|
||||
{'visible': {'title': _('Visible'), 'type': 'callback', 'width': '3em'}},
|
||||
{'small_name': {'title': _('Label')}},
|
||||
{'users_count': {'title': _('Users'), 'type': 'numeric', 'width': '5em'}},
|
||||
{'mfa_name': {'title': _('MFA'),}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
|
||||
@@ -87,16 +89,17 @@ class Authenticators(ModelHandler):
|
||||
'passwordLabel': _(type_.passwordLabel),
|
||||
'canCreateUsers': type_.createUser != auths.Authenticator.createUser, # type: ignore
|
||||
'isExternal': type_.isExternalSource,
|
||||
'supportsMFA': type_.providesMfa(),
|
||||
}
|
||||
# Not of my type
|
||||
return {}
|
||||
|
||||
def getGui(self, type_: str) -> typing.List[typing.Any]:
|
||||
try:
|
||||
tgui = auths.factory().lookup(type_)
|
||||
if tgui:
|
||||
authType = auths.factory().lookup(type_)
|
||||
if authType:
|
||||
g = self.addDefaultFields(
|
||||
tgui.guiDescription(),
|
||||
authType.guiDescription(),
|
||||
['name', 'comments', 'tags', 'priority', 'small_name'],
|
||||
)
|
||||
self.addField(
|
||||
@@ -106,16 +109,39 @@ class Authenticators(ModelHandler):
|
||||
'value': True,
|
||||
'label': ugettext('Visible'),
|
||||
'tooltip': ugettext(
|
||||
'If active, transport will be visible for users'
|
||||
'If active, authenticator will be visible for users'
|
||||
),
|
||||
'type': gui.InputField.CHECKBOX_TYPE,
|
||||
'order': 107,
|
||||
'tab': ugettext('Display'),
|
||||
'tab': gui.DISPLAY_TAB,
|
||||
},
|
||||
)
|
||||
# If supports mfa, add MFA provider selector field
|
||||
if authType.providesMfa():
|
||||
self.addField(
|
||||
g,
|
||||
{
|
||||
'name': 'mfa_id',
|
||||
'values': [gui.choiceItem('', _('None'))]
|
||||
+ gui.sortedChoices(
|
||||
[
|
||||
gui.choiceItem(v.uuid, v.name)
|
||||
for v in MFA.objects.all()
|
||||
]
|
||||
),
|
||||
'label': ugettext('MFA Provider'),
|
||||
'tooltip': ugettext(
|
||||
'MFA provider to use for this authenticator'
|
||||
),
|
||||
'type': gui.InputField.CHOICE_TYPE,
|
||||
'order': 108,
|
||||
'tab': gui.MFA_TAB,
|
||||
},
|
||||
)
|
||||
return g
|
||||
raise Exception() # Not found
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.info('Type not found: %s', e)
|
||||
raise NotFound('type not found')
|
||||
|
||||
def item_as_dict(self, item: Authenticator) -> typing.Dict[str, typing.Any]:
|
||||
@@ -127,6 +153,8 @@ class Authenticators(ModelHandler):
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'comments': item.comments,
|
||||
'priority': item.priority,
|
||||
'mfa_id': item.mfa.uuid if item.mfa else '',
|
||||
'mfa_name': item.mfa.name if item.mfa else '', # For overview
|
||||
'visible': item.visible,
|
||||
'small_name': item.small_name,
|
||||
'users_count': item.users.count(),
|
||||
@@ -182,6 +210,24 @@ class Authenticators(ModelHandler):
|
||||
return self.success()
|
||||
return res[1]
|
||||
|
||||
def beforeSave(
|
||||
self, fields: typing.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=processUuid(fields['mfa_id'])
|
||||
)
|
||||
fields['mfa_id'] = mfa.id
|
||||
return
|
||||
except MFA.DoesNotExist:
|
||||
pass # will set field to null
|
||||
|
||||
fields['mfa_id'] = None
|
||||
|
||||
|
||||
|
||||
def deleteItem(self, item: Authenticator):
|
||||
# For every user, remove assigned services (mark them for removal)
|
||||
|
||||
|
@@ -46,6 +46,8 @@ from uds.core.util.config import GlobalConfig
|
||||
from uds.core.services.exceptions import ServiceNotReadyError
|
||||
from uds.core import VERSION as UDS_VERSION
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.models import UserService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,6 +55,7 @@ CLIENT_VERSION = UDS_VERSION
|
||||
REQUIRED_CLIENT_VERSION = '3.5.0'
|
||||
|
||||
|
||||
|
||||
# Enclosed methods under /client path
|
||||
class Client(Handler):
|
||||
"""
|
||||
@@ -122,6 +125,7 @@ class Client(Handler):
|
||||
if len(self._args) == 1: # Simple test
|
||||
return Client.result(_('Correct'))
|
||||
|
||||
userService: typing.Optional['UserService'] = None
|
||||
try:
|
||||
(
|
||||
ticket,
|
||||
@@ -180,9 +184,6 @@ class Client(Handler):
|
||||
)
|
||||
password = cryptoManager().symDecrpyt(data['password'], scrambler)
|
||||
|
||||
# Set "accesedByClient"
|
||||
userService.setProperty('accessedByClient', '1')
|
||||
|
||||
# userService.setConnectionSource(srcIp, hostname) # Store where we are accessing from so we can notify Service
|
||||
if not ip:
|
||||
raise ServiceNotReadyError()
|
||||
@@ -218,10 +219,6 @@ class Client(Handler):
|
||||
}
|
||||
)
|
||||
except ServiceNotReadyError as e:
|
||||
# Set that client has accesed userService
|
||||
if e.userService:
|
||||
e.userService.setProperty('accessedByClient', '1')
|
||||
|
||||
# Refresh ticket and make this retrayable
|
||||
TicketStore.revalidate(
|
||||
ticket, 20
|
||||
@@ -232,3 +229,7 @@ class Client(Handler):
|
||||
except Exception as e:
|
||||
logger.exception("Exception")
|
||||
return Client.result(error=str(e))
|
||||
|
||||
finally:
|
||||
if userService:
|
||||
userService.setProperty('accessedByClient', '1')
|
||||
|
@@ -39,27 +39,6 @@ from uds.REST import Handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pair of section/value removed from current UDS version
|
||||
REMOVED = {
|
||||
'UDS': (
|
||||
'allowPreferencesAccess',
|
||||
'customHtmlLogin',
|
||||
'UDS Theme',
|
||||
'UDS Theme Enhaced',
|
||||
'css',
|
||||
'allowPreferencesAccess',
|
||||
'loginUrl',
|
||||
'maxLoginTries',
|
||||
'loginBlockTime',
|
||||
),
|
||||
'Cluster': ('Destination CPU Load', 'Migration CPU Load', 'Migration Free Memory'),
|
||||
'IPAUTH': ('autoLogin',),
|
||||
'VMWare': ('minUsableDatastoreGB', 'maxRetriesOnError'),
|
||||
'HyperV': ('minUsableDatastoreGB',),
|
||||
'Security': ('adminIdleTime', 'userSessionLength'),
|
||||
}
|
||||
|
||||
|
||||
# Enclosed methods under /config path
|
||||
class Config(Handler):
|
||||
needs_admin = True # By default, staff is lower level needed
|
||||
@@ -67,30 +46,8 @@ class Config(Handler):
|
||||
def get(self):
|
||||
cfg: CfgConfig.Value
|
||||
|
||||
res: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
|
||||
addCrypt = self.is_admin()
|
||||
return CfgConfig.getConfigValues(self.is_admin())
|
||||
|
||||
for cfg in CfgConfig.enumerate():
|
||||
# Skip removed configuration values, even if they are in database
|
||||
logger.debug('Key: %s, val: %s', cfg.section(), cfg.key())
|
||||
if cfg.key() in REMOVED.get(cfg.section(), ()):
|
||||
continue
|
||||
|
||||
if cfg.isCrypted() is True and addCrypt is False:
|
||||
continue
|
||||
|
||||
# add section if it do not exists
|
||||
if cfg.section() not in res:
|
||||
res[cfg.section()] = {}
|
||||
res[cfg.section()][cfg.key()] = {
|
||||
'value': cfg.get(),
|
||||
'crypt': cfg.isCrypted(),
|
||||
'longText': cfg.isLongText(),
|
||||
'type': cfg.getType(),
|
||||
'params': cfg.getParams(),
|
||||
}
|
||||
logger.debug('Configuration: %s', res)
|
||||
return res
|
||||
|
||||
def put(self):
|
||||
for section, secDict in self._params.items():
|
||||
|
118
server/src/uds/REST/methods/mfas.py
Normal file
118
server/src/uds/REST/methods/mfas.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# -*- 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.
|
||||
|
||||
'''
|
||||
@itemor: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
'''
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _, gettext
|
||||
from uds import models
|
||||
from uds.core import mfas
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import permissions
|
||||
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
class MFA(ModelHandler):
|
||||
model = models.MFA
|
||||
save_fields = ['name', 'comments', 'tags', 'remember_device', 'validity']
|
||||
|
||||
table_title = _('Multi Factor Authentication')
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
|
||||
def enum_types(self) -> typing.Iterable[typing.Type[mfas.MFA]]:
|
||||
return mfas.factory().providers().values()
|
||||
|
||||
def getGui(self, type_: str) -> typing.List[typing.Any]:
|
||||
mfa = mfas.factory().lookup(type_)
|
||||
|
||||
if not mfa:
|
||||
raise self.invalidItemException()
|
||||
|
||||
localGui = self.addDefaultFields(
|
||||
mfa.guiDescription(), ['name', 'comments', 'tags']
|
||||
)
|
||||
self.addField(
|
||||
localGui,
|
||||
{
|
||||
'name': 'remember_device',
|
||||
'value': '0',
|
||||
'minValue': '0',
|
||||
'label': gettext('Device Caching'),
|
||||
'tooltip': gettext(
|
||||
'Time in hours to cache device so MFA is not required again. User based.'
|
||||
),
|
||||
'type': gui.InputField.NUMERIC_TYPE,
|
||||
'order': 111,
|
||||
},
|
||||
)
|
||||
self.addField(
|
||||
localGui,
|
||||
{
|
||||
'name': 'validity',
|
||||
'value': '5',
|
||||
'minValue': '0',
|
||||
'label': gettext('MFA code validity'),
|
||||
'tooltip': gettext(
|
||||
'Time in minutes to allow MFA code to be used.'
|
||||
),
|
||||
'type': gui.InputField.NUMERIC_TYPE,
|
||||
'order': 112,
|
||||
},
|
||||
|
||||
)
|
||||
|
||||
return localGui
|
||||
|
||||
def item_as_dict(self, item: models.MFA) -> typing.Dict[str, typing.Any]:
|
||||
type_ = item.getType()
|
||||
return {
|
||||
'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_.type(),
|
||||
'type_name': type_.name(),
|
||||
'permission': permissions.getEffectivePermission(self._user, item),
|
||||
}
|
@@ -80,21 +80,21 @@ class Users(DetailHandler):
|
||||
|
||||
custom_methods = ['servicesPools', 'userServices']
|
||||
|
||||
@staticmethod
|
||||
def uuid_to_id(iterator):
|
||||
for v in iterator:
|
||||
v['id'] = v['uuid']
|
||||
del v['uuid']
|
||||
yield v
|
||||
|
||||
def getItems(self, parent: Authenticator, item: typing.Optional[str]):
|
||||
# processes item to change uuid key for id
|
||||
def uuid_to_id(iterable: typing.Iterable[typing.MutableMapping[str, typing.Any]]):
|
||||
for v in iterable:
|
||||
v['id'] = v['uuid']
|
||||
del v['uuid']
|
||||
yield v
|
||||
|
||||
logger.debug(item)
|
||||
# Extract authenticator
|
||||
try:
|
||||
if item is None:
|
||||
values = list(
|
||||
Users.uuid_to_id(
|
||||
parent.users.all().values(
|
||||
uuid_to_id(
|
||||
(i for i in parent.users.all().values(
|
||||
'uuid',
|
||||
'name',
|
||||
'real_name',
|
||||
@@ -104,7 +104,8 @@ class Users(DetailHandler):
|
||||
'is_admin',
|
||||
'last_access',
|
||||
'parent',
|
||||
)
|
||||
'mfa_data',
|
||||
))
|
||||
)
|
||||
)
|
||||
for res in values:
|
||||
@@ -127,6 +128,7 @@ class Users(DetailHandler):
|
||||
'is_admin',
|
||||
'last_access',
|
||||
'parent',
|
||||
'mfa_data',
|
||||
),
|
||||
)
|
||||
res['id'] = u.uuid
|
||||
@@ -153,7 +155,7 @@ class Users(DetailHandler):
|
||||
except Exception:
|
||||
return _('Current users')
|
||||
|
||||
def getFields(self, parent):
|
||||
def getFields(self, parent: Authenticator):
|
||||
return [
|
||||
{
|
||||
'name': {
|
||||
@@ -198,12 +200,16 @@ class Users(DetailHandler):
|
||||
'staff_member',
|
||||
'is_admin',
|
||||
]
|
||||
if self._params.get('name', '') == '':
|
||||
if self._params.get('name', '').strip() == '':
|
||||
raise 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.readFieldsFromParams(valid_fields)
|
||||
if not self._user.is_admin:
|
||||
@@ -224,9 +230,8 @@ class Users(DetailHandler):
|
||||
user.__dict__.update(fields)
|
||||
|
||||
logger.debug('User parent: %s', user.parent)
|
||||
if auth.isExternalSource is False and (
|
||||
user.parent is None or user.parent == ''
|
||||
):
|
||||
# If internal auth, threat it "special"
|
||||
if auth.isExternalSource is False and not user.parent:
|
||||
groups = self.readFieldsFromParams(['groups'])['groups']
|
||||
logger.debug('Groups: %s', groups)
|
||||
logger.debug('Got Groups %s', parent.groups.filter(uuid__in=groups))
|
||||
@@ -414,7 +419,7 @@ class Groups(DetailHandler):
|
||||
except Exception:
|
||||
raise self.invalidRequestException()
|
||||
|
||||
def saveItem(self, parent: Authenticator, item) -> None:
|
||||
def saveItem(self, parent: Authenticator, item: typing.Optional[str]) -> None:
|
||||
group = None # Avoid warning on reference before assignment
|
||||
try:
|
||||
is_meta = self._params['type'] == 'meta'
|
||||
@@ -429,7 +434,7 @@ class Groups(DetailHandler):
|
||||
fields = self.readFieldsFromParams(valid_fields)
|
||||
is_pattern = fields.get('name', '').find('pat:') == 0
|
||||
auth = parent.getInstance()
|
||||
if item is None: # Create new
|
||||
if not item: # Create new
|
||||
if not is_meta and not is_pattern:
|
||||
auth.createGroup(
|
||||
fields
|
||||
@@ -482,7 +487,9 @@ class Groups(DetailHandler):
|
||||
except Exception:
|
||||
raise self.invalidItemException()
|
||||
|
||||
def servicesPools(self, parent: Authenticator, item: str) -> typing.List[typing.Mapping[str, typing.Any]]:
|
||||
def servicesPools(
|
||||
self, parent: Authenticator, item: str
|
||||
) -> typing.List[typing.Mapping[str, typing.Any]]:
|
||||
uuid = processUuid(item)
|
||||
group = parent.groups.get(uuid=processUuid(uuid))
|
||||
res: typing.List[typing.Mapping[str, typing.Any]] = []
|
||||
@@ -503,7 +510,9 @@ class Groups(DetailHandler):
|
||||
|
||||
return res
|
||||
|
||||
def users(self, parent: Authenticator, item: str) -> typing.List[typing.Mapping[str, typing.Any]]:
|
||||
def users(
|
||||
self, parent: Authenticator, item: str
|
||||
) -> typing.List[typing.Mapping[str, typing.Any]]:
|
||||
uuid = processUuid(item)
|
||||
group = parent.groups.get(uuid=processUuid(uuid))
|
||||
|
||||
|
@@ -114,8 +114,8 @@ class BaseModelHandler(Handler):
|
||||
'values': field.get('values', []),
|
||||
},
|
||||
}
|
||||
if 'tab' in field:
|
||||
v['gui']['tab'] = field['tab']
|
||||
if field.get('tab', None):
|
||||
v['gui']['tab'] = _(field['tab'])
|
||||
gui.append(v)
|
||||
return gui
|
||||
|
||||
@@ -268,7 +268,11 @@ class BaseModelHandler(Handler):
|
||||
args: typing.Dict[str, str] = {}
|
||||
try:
|
||||
for key in fldList:
|
||||
args[key] = self._params[key]
|
||||
if ':' in key: # optional field? get default if not present
|
||||
k, default = key.split(':')[:2]
|
||||
args[k] = self._params.get(k, default)
|
||||
else:
|
||||
args[key] = self._params[key]
|
||||
# del self._params[key]
|
||||
except KeyError as e:
|
||||
raise RequestError('needed parameter not found in data {0}'.format(e))
|
||||
@@ -675,7 +679,9 @@ class ModelHandler(BaseModelHandler):
|
||||
detail: typing.ClassVar[
|
||||
typing.Optional[typing.Dict[str, typing.Type[DetailHandler]]]
|
||||
] = None # Dictionary containing detail routing
|
||||
# Put needed fields
|
||||
# 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
|
||||
# Note that these fields has to be present in the model, and they can be "edited" in the beforeSave method
|
||||
save_fields: typing.ClassVar[typing.List[str]] = []
|
||||
# Put removable fields before updating
|
||||
remove_fields: typing.ClassVar[typing.List[str]] = []
|
||||
|
@@ -71,6 +71,7 @@ class UDSAppConfig(AppConfig):
|
||||
# pylint: disable=unused-import
|
||||
from . import services # to make sure that the packages are initialized at this point
|
||||
from . import auths # To make sure that the packages are initialized at this point
|
||||
from . import mfas # To make sure mfas are loaded on memory
|
||||
from . import osmanagers # To make sure that packages are initialized at this point
|
||||
from . import transports # To make sure that packages are initialized at this point
|
||||
from . import dispatchers # Ensure all dischatchers all also available
|
||||
|
@@ -99,12 +99,18 @@ class InternalDBAuth(auths.Authenticator):
|
||||
if self.reverseDns.isTrue():
|
||||
try:
|
||||
return str(
|
||||
dns.resolver.query(dns.reversename.from_address(ip), 'PTR')[0]
|
||||
dns.resolver.query(dns.reversename.from_address(ip).to_text(), 'PTR')[0]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return ip
|
||||
|
||||
def mfaIdentifier(self, username: str) -> str:
|
||||
try:
|
||||
return self.dbAuthenticator().users.get(name=username, state=State.ACTIVE).mfa_data
|
||||
finally:
|
||||
return ''
|
||||
|
||||
def transformUsername(self, username: str) -> str:
|
||||
if self.differentForEachHost.isTrue():
|
||||
newUsername = self.getIp() + '-' + username
|
||||
|
@@ -112,6 +112,15 @@ class RadiusAuth(auths.Authenticator):
|
||||
tooltip=_('If set, this value will be added as group for all radius users'),
|
||||
tab=gui.ADVANCED_TAB,
|
||||
)
|
||||
mfaAttr = gui.TextField(
|
||||
length=2048,
|
||||
multiline=2,
|
||||
label=_('MFA attribute'),
|
||||
order=13,
|
||||
tooltip=_('Attribute from where to extract the MFA code'),
|
||||
required=False,
|
||||
tab=gui.MFA_TAB,
|
||||
)
|
||||
|
||||
def initialize(self, values: typing.Optional[typing.Dict[str, typing.Any]]) -> None:
|
||||
pass
|
||||
@@ -126,12 +135,25 @@ class RadiusAuth(auths.Authenticator):
|
||||
appClassPrefix=self.appClassPrefix.value,
|
||||
)
|
||||
|
||||
def mfaStorageKey(self, username: str) -> str:
|
||||
return 'mfa_' + str(self.dbAuthenticator().uuid) + username
|
||||
|
||||
def mfaIdentifier(self, username: str) -> str:
|
||||
return self.storage.getPickle(self.mfaStorageKey(username)) or ''
|
||||
|
||||
def authenticate(
|
||||
self, username: str, credentials: str, groupsManager: 'auths.GroupsManager'
|
||||
) -> bool:
|
||||
try:
|
||||
connection = self.radiusClient()
|
||||
groups = connection.authenticate(username=username, password=credentials)
|
||||
groups, mfaCode = connection.authenticate(username=username, password=credentials, mfaField=self.mfaAttr.value.strip())
|
||||
# store the user mfa attribute if it is set
|
||||
if mfaCode:
|
||||
self.storage.putPickle(
|
||||
self.mfaStorageKey(username),
|
||||
mfaCode,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
authLogLogin(getRequest(), self.dbAuthenticator(), username, 'Access denied by Raiuds')
|
||||
return False
|
||||
@@ -178,7 +200,7 @@ class RadiusAuth(auths.Authenticator):
|
||||
try:
|
||||
connection = self.radiusClient()
|
||||
# Reply is not important...
|
||||
connection.authenticate(cryptoManager().randomString(10), cryptoManager().randomString(10))
|
||||
connection.authenticate(cryptoManager().randomString(10), cryptoManager().randomString(10), mfaField=self.mfaAttr.value.strip())
|
||||
except client.RadiusAuthenticationError as e:
|
||||
pass
|
||||
except Exception:
|
||||
|
@@ -1,16 +1,12 @@
|
||||
import io
|
||||
import logging
|
||||
import enum
|
||||
import typing
|
||||
|
||||
from pyrad.client import Client
|
||||
from pyrad.dictionary import Dictionary
|
||||
import pyrad.packet
|
||||
|
||||
__all__ = ['RadiusClient', 'RadiusAuthenticationError', 'RADDICT']
|
||||
|
||||
class RadiusAuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RADDICT = """ATTRIBUTE User-Name 1 string
|
||||
@@ -51,6 +47,36 @@ ATTRIBUTE Framed-AppleTalk-Link 37 integer
|
||||
ATTRIBUTE Framed-AppleTalk-Network 38 integer
|
||||
ATTRIBUTE Framed-AppleTalk-Zone 39 string"""
|
||||
|
||||
# For AccessChallenge return values
|
||||
NOT_CHECKED, INCORRECT, CORRECT = -1, 0, 1 # for pwd and otp
|
||||
NOT_NEEDED, NEEDED = INCORRECT, CORRECT # for otp_needed
|
||||
|
||||
|
||||
class RadiusAuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RadiusStates(enum.IntEnum):
|
||||
NOT_CHECKED = -1
|
||||
INCORRECT = 0
|
||||
CORRECT = 1
|
||||
|
||||
# Aliases
|
||||
NOT_NEEDED = INCORRECT
|
||||
NEEDED = CORRECT
|
||||
|
||||
|
||||
class RadiusResult(typing.NamedTuple):
|
||||
"""
|
||||
Result of an AccessChallenge request.
|
||||
"""
|
||||
|
||||
pwd: RadiusStates = RadiusStates.INCORRECT
|
||||
replyMessage: typing.Optional[bytes] = None
|
||||
state: typing.Optional[bytes] = None
|
||||
otp: RadiusStates = RadiusStates.NOT_CHECKED
|
||||
otp_needed: RadiusStates = RadiusStates.NOT_CHECKED
|
||||
|
||||
|
||||
class RadiusClient:
|
||||
radiusServer: Client
|
||||
@@ -68,12 +94,27 @@ class RadiusClient:
|
||||
dictionary: str = RADDICT,
|
||||
) -> None:
|
||||
self.radiusServer = Client(
|
||||
server=server, authport=authPort, secret=secret, dict=Dictionary(io.StringIO(dictionary))
|
||||
server=server,
|
||||
authport=authPort,
|
||||
secret=secret,
|
||||
dict=Dictionary(io.StringIO(dictionary)),
|
||||
)
|
||||
self.nasIdentifier = nasIdentifier
|
||||
self.appClassPrefix = appClassPrefix
|
||||
|
||||
def authenticate(self, username: str, password: str) -> typing.List[str]:
|
||||
def extractAccessChallenge(self, reply: pyrad.packet.AuthPacket) -> RadiusResult:
|
||||
return RadiusResult(
|
||||
pwd=RadiusStates.CORRECT,
|
||||
replyMessage=typing.cast(
|
||||
typing.List[bytes], reply.get('Reply-Message') or ['']
|
||||
)[0],
|
||||
state=typing.cast(typing.List[bytes], reply.get('State') or [b''])[0],
|
||||
otp_needed=RadiusStates.NEEDED,
|
||||
)
|
||||
|
||||
def sendAccessRequest(
|
||||
self, username: str, password: str, **kwargs
|
||||
) -> pyrad.packet.AuthPacket:
|
||||
req: pyrad.packet.AuthPacket = self.radiusServer.CreateAuthPacket(
|
||||
code=pyrad.packet.AccessRequest,
|
||||
User_Name=username,
|
||||
@@ -82,9 +123,19 @@ class RadiusClient:
|
||||
|
||||
req["User-Password"] = req.PwCrypt(password)
|
||||
|
||||
reply = typing.cast(pyrad.packet.AuthPacket, self.radiusServer.SendPacket(req))
|
||||
# Fill in extra fields
|
||||
for k, v in kwargs.items():
|
||||
req[k] = v
|
||||
|
||||
if reply.code != pyrad.packet.AccessAccept:
|
||||
return typing.cast(pyrad.packet.AuthPacket, self.radiusServer.SendPacket(req))
|
||||
|
||||
# Second element of return value is the mfa code from field
|
||||
def authenticate(
|
||||
self, username: str, password: str, mfaField: str
|
||||
) -> typing.Tuple[typing.List[str], str]:
|
||||
reply = self.sendAccessRequest(username, password)
|
||||
|
||||
if reply.code not in (pyrad.packet.AccessAccept, pyrad.packet.AccessChallenge):
|
||||
raise RadiusAuthenticationError('Access denied')
|
||||
|
||||
# User accepted, extract groups...
|
||||
@@ -92,10 +143,111 @@ class RadiusClient:
|
||||
groupClassPrefix = (self.appClassPrefix + 'group=').encode()
|
||||
groupClassPrefixLen = len(groupClassPrefix)
|
||||
if 'Class' in reply:
|
||||
groups = [i[groupClassPrefixLen:].decode() for i in typing.cast(typing.Iterable[bytes], reply['Class']) if i.startswith(groupClassPrefix)]
|
||||
groups = [
|
||||
i[groupClassPrefixLen:].decode()
|
||||
for i in typing.cast(typing.Iterable[bytes], reply['Class'])
|
||||
if i.startswith(groupClassPrefix)
|
||||
]
|
||||
else:
|
||||
logger.info('No "Class (25)" attribute found')
|
||||
return []
|
||||
return ([], '')
|
||||
|
||||
return groups
|
||||
# ...and mfa code
|
||||
mfaCode = ''
|
||||
if mfaField and mfaField in reply:
|
||||
mfaCode = ''.join(
|
||||
i[groupClassPrefixLen:].decode()
|
||||
for i in typing.cast(typing.Iterable[bytes], reply['Class'])
|
||||
if i.startswith(groupClassPrefix)
|
||||
)
|
||||
return (groups, mfaCode)
|
||||
|
||||
def authenticate_only(self, username: str, password: str) -> RadiusResult:
|
||||
reply = self.sendAccessRequest(username, password)
|
||||
|
||||
if reply.code == pyrad.packet.AccessChallenge:
|
||||
return self.extractAccessChallenge(reply)
|
||||
|
||||
# user/pwd accepted: this user does not have challenge data
|
||||
if reply.code == pyrad.packet.AccessAccept:
|
||||
return RadiusResult(
|
||||
pwd=RadiusStates.CORRECT,
|
||||
otp_needed=RadiusStates.NOT_CHECKED,
|
||||
)
|
||||
|
||||
# user/pwd rejected
|
||||
return RadiusResult(
|
||||
pwd=RadiusStates.INCORRECT,
|
||||
)
|
||||
|
||||
def challenge_only(
|
||||
self, username: str, otp: str, state: bytes = b'0000000000000000'
|
||||
) -> RadiusResult:
|
||||
|
||||
# clean otp code
|
||||
otp = ''.join([x for x in otp if x in '0123456789'])
|
||||
|
||||
reply = self.sendAccessRequest(username, otp, State=state)
|
||||
|
||||
# correct OTP challenge
|
||||
if reply.code == pyrad.packet.AccessAccept:
|
||||
return RadiusResult(
|
||||
otp=RadiusStates.CORRECT,
|
||||
)
|
||||
|
||||
# incorrect OTP challenge
|
||||
return RadiusResult(
|
||||
otp=RadiusStates.INCORRECT,
|
||||
)
|
||||
|
||||
def authenticate_and_challenge(
|
||||
self, username: str, password: str, otp: str
|
||||
) -> RadiusResult:
|
||||
reply = self.sendAccessRequest(username, password)
|
||||
|
||||
if reply.code == pyrad.packet.AccessChallenge:
|
||||
state = typing.cast(typing.List[bytes], reply.get('State') or [b''])[0]
|
||||
replyMessage = typing.cast(
|
||||
typing.List[bytes], reply.get('Reply-Message') or ['']
|
||||
)[0]
|
||||
return self.challenge_only(username, otp, state=state)
|
||||
|
||||
# user/pwd accepted: but this user does not have challenge data
|
||||
# we should not be here...
|
||||
if reply.code == pyrad.packet.AccessAccept:
|
||||
logger.warning(
|
||||
"Radius OTP error: cheking for OTP for not needed user [%s]", username
|
||||
)
|
||||
return RadiusResult(
|
||||
pwd=RadiusStates.CORRECT,
|
||||
otp_needed=RadiusStates.NOT_NEEDED,
|
||||
)
|
||||
|
||||
# TODO: accept more AccessChallenge authentications (as RFC says)
|
||||
|
||||
# incorrect user/pwd
|
||||
return RadiusResult()
|
||||
|
||||
def authenticate_challenge(
|
||||
self, username: str, password: str = '', otp: str = '', state: bytes = b''
|
||||
) -> RadiusResult:
|
||||
'''
|
||||
wrapper for above 3 functions: authenticate_only, challenge_only, authenticate_and_challenge
|
||||
calls wrapped functions based on passed input values: (pwd/otp/state)
|
||||
'''
|
||||
# clean input data
|
||||
otp = ''.join([x for x in otp if x in '0123456789'])
|
||||
username = username.strip()
|
||||
password = password.strip()
|
||||
state = state.strip()
|
||||
|
||||
if not username or (not password and not otp):
|
||||
return RadiusResult() # no user/pwd provided
|
||||
|
||||
if not otp:
|
||||
return self.authenticate_only(username, password)
|
||||
if otp and not password:
|
||||
# check only otp with static/invented state. allow this ?
|
||||
return self.challenge_only(username, otp, state=b'0000000000000000')
|
||||
# otp and password
|
||||
return self.authenticate_and_challenge(username, password, otp)
|
||||
|
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2012-2019 Virtual Cable S.L.
|
||||
# Copyright (c) 2012-2022 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
@@ -12,7 +12,7 @@
|
||||
# * 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
|
||||
# * 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.
|
||||
#
|
||||
@@ -176,6 +176,16 @@ class RegexLdap(auths.Authenticator):
|
||||
tab=_('Advanced'),
|
||||
)
|
||||
|
||||
mfaAttr = gui.TextField(
|
||||
length=2048,
|
||||
multiline=2,
|
||||
label=_('MFA attribute'),
|
||||
order=13,
|
||||
tooltip=_('Attribute from where to extract the MFA code'),
|
||||
required=False,
|
||||
tab=gui.MFA_TAB,
|
||||
)
|
||||
|
||||
typeName = _('Regex LDAP Authenticator')
|
||||
typeType = 'RegexLdapAuthenticator'
|
||||
typeDescription = _('Regular Expressions LDAP authenticator')
|
||||
@@ -205,6 +215,7 @@ class RegexLdap(auths.Authenticator):
|
||||
_groupNameAttr: str = ''
|
||||
_userNameAttr: str = ''
|
||||
_altClass: str = ''
|
||||
_mfaAttr: str = ''
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -231,6 +242,7 @@ class RegexLdap(auths.Authenticator):
|
||||
# self._regex = values['regex']
|
||||
self._userNameAttr = values['userNameAttr']
|
||||
self._altClass = values['altClass']
|
||||
self._mfaAttr = values['mfaAttr']
|
||||
|
||||
def __validateField(self, field: str, fieldLabel: str) -> None:
|
||||
"""
|
||||
@@ -296,6 +308,12 @@ class RegexLdap(auths.Authenticator):
|
||||
logger.debug('Res: %s', res)
|
||||
return res
|
||||
|
||||
def mfaStorageKey(self, username: str) -> str:
|
||||
return 'mfa_' + self.dbAuthenticator().uuid + username
|
||||
|
||||
def mfaIdentifier(self, username: str) -> str:
|
||||
return self.storage.getPickle(self.mfaStorageKey(username)) or ''
|
||||
|
||||
def valuesDict(self) -> gui.ValuesDictType:
|
||||
return {
|
||||
'host': self._host,
|
||||
@@ -310,12 +328,13 @@ class RegexLdap(auths.Authenticator):
|
||||
'groupNameAttr': self._groupNameAttr,
|
||||
'userNameAttr': self._userNameAttr,
|
||||
'altClass': self._altClass,
|
||||
'mfaAttr': self._mfaAttr,
|
||||
}
|
||||
|
||||
def marshal(self) -> bytes:
|
||||
return '\t'.join(
|
||||
[
|
||||
'v3',
|
||||
'v4',
|
||||
self._host,
|
||||
self._port,
|
||||
gui.boolToStr(self._ssl),
|
||||
@@ -328,6 +347,7 @@ class RegexLdap(auths.Authenticator):
|
||||
self._groupNameAttr,
|
||||
self._userNameAttr,
|
||||
self._altClass,
|
||||
self._mfaAttr,
|
||||
]
|
||||
).encode('utf8')
|
||||
|
||||
@@ -385,6 +405,24 @@ class RegexLdap(auths.Authenticator):
|
||||
self._altClass,
|
||||
) = vals[1:]
|
||||
self._ssl = gui.strToBool(ssl)
|
||||
elif vals[0] == 'v4':
|
||||
logger.debug("Data v4: %s", vals[1:])
|
||||
(
|
||||
self._host,
|
||||
self._port,
|
||||
ssl,
|
||||
self._username,
|
||||
self._password,
|
||||
self._timeout,
|
||||
self._ldapBase,
|
||||
self._userClass,
|
||||
self._userIdAttr,
|
||||
self._groupNameAttr,
|
||||
self._userNameAttr,
|
||||
self._altClass,
|
||||
self._mfaAttr,
|
||||
) = vals[1:]
|
||||
self._ssl = gui.strToBool(ssl)
|
||||
|
||||
def __connection(self) -> typing.Any:
|
||||
"""
|
||||
@@ -428,6 +466,9 @@ class RegexLdap(auths.Authenticator):
|
||||
+ self.__getAttrsFromField(self._userNameAttr)
|
||||
+ self.__getAttrsFromField(self._groupNameAttr)
|
||||
)
|
||||
if self._mfaAttr:
|
||||
attributes = attributes + self.__getAttrsFromField(self._mfaAttr)
|
||||
|
||||
user = ldaputil.getFirst(
|
||||
con=self.__connection(),
|
||||
base=self._ldapBase,
|
||||
@@ -517,6 +558,13 @@ class RegexLdap(auths.Authenticator):
|
||||
)
|
||||
return False
|
||||
|
||||
# store the user mfa attribute if it is set
|
||||
if self._mfaAttr:
|
||||
self.storage.putPickle(
|
||||
self.mfaStorageKey(username),
|
||||
usr[self._mfaAttr][0],
|
||||
)
|
||||
|
||||
groupsManager.validate(self.__getGroups(usr))
|
||||
|
||||
return True
|
||||
|
@@ -40,5 +40,5 @@ from .serializable import Serializable
|
||||
from .module import Module
|
||||
|
||||
|
||||
VERSION = '3.5.0'
|
||||
VERSION_STAMP = '{}'.format(time.strftime("%Y%m%d"))
|
||||
VERSION = '3.x.x-DEVEL'
|
||||
VERSION_STAMP = '{}-DEVEL'.format(time.strftime("%Y%m%d"))
|
||||
|
@@ -69,6 +69,7 @@ authLogger = logging.getLogger('authLog')
|
||||
USER_KEY = 'uk'
|
||||
PASS_KEY = 'pk'
|
||||
EXPIRY_KEY = 'ek'
|
||||
AUTHORIZED_KEY = 'ak'
|
||||
ROOT_ID = -20091204 # Any negative number will do the trick
|
||||
UDS_COOKIE_LENGTH = 48
|
||||
|
||||
@@ -122,32 +123,45 @@ def getRootUser() -> User:
|
||||
|
||||
# Decorator to make easier protect pages that needs to be logged in
|
||||
def webLoginRequired(
|
||||
admin: typing.Union[bool, str] = False
|
||||
) -> typing.Callable[[typing.Callable[..., RT]], typing.Callable[..., RT]]:
|
||||
"""
|
||||
Decorator to set protection to access page
|
||||
admin: typing.Union[bool, typing.Literal['admin']] = False
|
||||
) -> typing.Callable[
|
||||
[typing.Callable[..., HttpResponse]], typing.Callable[..., HttpResponse]
|
||||
]:
|
||||
"""Decorator to set protection to access page
|
||||
Look for samples at uds.core.web.views
|
||||
if admin == True, needs admin or staff
|
||||
if admin == 'admin', needs admin
|
||||
|
||||
Args:
|
||||
admin (bool, optional): If True, needs admin or staff. Is it's "admin" literal, needs admin . Defaults to False (any user).
|
||||
|
||||
Returns:
|
||||
typing.Callable[[typing.Callable[..., HttpResponse]], typing.Callable[..., HttpResponse]]: Decorator
|
||||
|
||||
Note:
|
||||
This decorator is used to protect pages that needs to be logged in.
|
||||
To protect against ajax calls, use `denyNonAuthenticated` instead
|
||||
"""
|
||||
|
||||
def decorator(view_func: typing.Callable[..., RT]) -> typing.Callable[..., RT]:
|
||||
def _wrapped_view(request: 'ExtendedHttpRequest', *args, **kwargs) -> RT:
|
||||
def decorator(
|
||||
view_func: typing.Callable[..., HttpResponse]
|
||||
) -> typing.Callable[..., HttpResponse]:
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(
|
||||
request: 'ExtendedHttpRequest', *args, **kwargs
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
Wrapped function for decorator
|
||||
"""
|
||||
if not request.user:
|
||||
# url = request.build_absolute_uri(GlobalConfig.LOGIN_URL.get())
|
||||
# if GlobalConfig.REDIRECT_TO_HTTPS.getBool() is True:
|
||||
# url = url.replace('http://', 'https://')
|
||||
# logger.debug('No user found, redirecting to %s', url)
|
||||
return HttpResponseRedirect(reverse('page.login')) # type: ignore
|
||||
# If no user or user authorization is not completed...
|
||||
if not request.user or not request.authorized:
|
||||
return HttpResponseRedirect(reverse('page.login'))
|
||||
|
||||
if admin is True or admin == 'admin': # bool or string "admin"
|
||||
if admin in (True, 'admin'):
|
||||
if request.user.isStaff() is False or (
|
||||
admin == 'admin' and not request.user.is_admin
|
||||
):
|
||||
return HttpResponseForbidden(_('Forbidden')) # type: ignore
|
||||
return HttpResponseForbidden(_('Forbidden'))
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
@@ -167,7 +181,6 @@ def trustedSourceRequired(
|
||||
) -> typing.Callable[..., RT]:
|
||||
"""
|
||||
Decorator to set protection to access page
|
||||
look for sample at uds.dispatchers.pam
|
||||
"""
|
||||
|
||||
@wraps(view_func)
|
||||
@@ -189,12 +202,14 @@ def trustedSourceRequired(
|
||||
|
||||
|
||||
# decorator to deny non authenticated requests
|
||||
# The difference with webLoginRequired is that this one does not redirect to login page
|
||||
# it's designed to be used in ajax calls mainly
|
||||
def denyNonAuthenticated(
|
||||
view_func: typing.Callable[..., RT]
|
||||
) -> typing.Callable[..., RT]:
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(request: 'ExtendedHttpRequest', *args, **kwargs) -> RT:
|
||||
if not request.user:
|
||||
if not request.user or not request.authorized:
|
||||
return HttpResponseForbidden() # type: ignore
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
@@ -372,6 +387,13 @@ def webLogin(
|
||||
cookie = getUDSCookie(request, response)
|
||||
|
||||
user.updateLastAccess()
|
||||
request.authorized = (
|
||||
False # For now, we don't know if the user is authorized until MFA is checked
|
||||
)
|
||||
# If Enabled zero trust, do not cache credentials
|
||||
if GlobalConfig.ENFORCE_ZERO_TRUST.getBool(False):
|
||||
password = ''
|
||||
|
||||
request.session[USER_KEY] = user.id
|
||||
request.session[PASS_KEY] = cryptoManager().symCrypt(
|
||||
password, cookie
|
||||
@@ -414,12 +436,7 @@ def webLogout(
|
||||
Helper function to clear user related data from session. If this method is not used, the session we be cleaned anyway
|
||||
by django in regular basis.
|
||||
"""
|
||||
if exit_url is None:
|
||||
exit_url = request.build_absolute_uri(reverse('page.login'))
|
||||
# exit_url = GlobalConfig.LOGIN_URL.get()
|
||||
# if GlobalConfig.REDIRECT_TO_HTTPS.getBool() is True:
|
||||
# exit_url = exit_url.replace('http://', 'https://')
|
||||
|
||||
exit_url = exit_url or reverse('page.login')
|
||||
try:
|
||||
if request.user:
|
||||
authenticator = request.user.manager.getInstance()
|
||||
@@ -440,8 +457,9 @@ def webLogout(
|
||||
finally:
|
||||
# Try to delete session
|
||||
request.session.flush()
|
||||
request.authorized = False
|
||||
|
||||
response = HttpResponseRedirect(request.build_absolute_uri(exit_url))
|
||||
response = HttpResponseRedirect(exit_url)
|
||||
if authenticator:
|
||||
authenticator.webLogoutHook(username, request, response)
|
||||
return response
|
||||
@@ -486,7 +504,9 @@ def authLogLogin(
|
||||
log.doLog(
|
||||
user,
|
||||
level,
|
||||
'{} from {} where OS is {}'.format(logStr, request.ip, request.os['OS'].value[0]),
|
||||
'{} from {} where OS is {}'.format(
|
||||
logStr, request.ip, request.os['OS'].value[0]
|
||||
),
|
||||
log.WEB,
|
||||
)
|
||||
except Exception:
|
||||
|
@@ -290,6 +290,25 @@ class Authenticator(Module): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
return []
|
||||
|
||||
def mfaIdentifier(self, username: str) -> str:
|
||||
"""
|
||||
If this method is provided by an authenticator, the user will be allowed to enter a MFA code
|
||||
You must return the value used by a MFA provider to identify the user (i.e. email, phone number, etc)
|
||||
If not provided, or the return value is '', the user will be allowed to access UDS without MFA
|
||||
|
||||
Note: Field capture will be responsible of provider. Put it on MFA tab of user form.
|
||||
Take into consideration that mfaIdentifier will never be invoked if the user has not been
|
||||
previously authenticated. (that is, authenticate method has already been called)
|
||||
"""
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def providesMfa(cls) -> bool:
|
||||
"""
|
||||
Returns if this authenticator provides a MFA identifier
|
||||
"""
|
||||
return cls.mfaIdentifier is not Authenticator.mfaIdentifier
|
||||
|
||||
def authenticate(
|
||||
self, username: str, credentials: str, groupsManager: 'GroupsManager'
|
||||
) -> bool:
|
||||
|
@@ -37,27 +37,45 @@ class AuthenticatorException(Exception):
|
||||
Generic authentication exception
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidUserException(AuthenticatorException):
|
||||
"""
|
||||
Invalid user specified. The user cant access the requested service
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAuthenticatorException(AuthenticatorException):
|
||||
"""
|
||||
Invalida authenticator has been specified
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class Redirect(Exception):
|
||||
|
||||
class Redirect(AuthenticatorException):
|
||||
"""
|
||||
This exception indicates that a redirect is required.
|
||||
Used in authUrlCallback to indicate that redirect is needed
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class Logout(Exception):
|
||||
|
||||
class Logout(AuthenticatorException):
|
||||
"""
|
||||
This exceptions redirects logouts an user and redirects to an url
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MFAError(AuthenticatorException):
|
||||
"""
|
||||
This exceptions indicates than an MFA error has ocurred
|
||||
"""
|
||||
|
||||
pass
|
||||
|
@@ -30,6 +30,7 @@
|
||||
"""
|
||||
@author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import typing
|
||||
import logging
|
||||
|
||||
from uds.core.environment import Environmentable, Environment
|
||||
@@ -43,11 +44,11 @@ class DelayedTask(Environmentable):
|
||||
This is an object that represents an execution to be done "later"
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, environment: typing.Optional[Environment] = None) -> None:
|
||||
"""
|
||||
Remember to invoke parent init in derived clases using super(myClass,self).__init__() to let this initialize its own variables
|
||||
"""
|
||||
super().__init__(Environment('DelayedTask'))
|
||||
super().__init__(environment or Environment.getEnvForType(self.__class__))
|
||||
|
||||
def execute(self) -> None:
|
||||
"""
|
||||
|
@@ -139,6 +139,7 @@ class DelayedTaskRunner:
|
||||
|
||||
if taskInstance:
|
||||
logger.debug('Executing delayedTask:>%s<', task)
|
||||
# Re-create environment data
|
||||
taskInstance.env = Environment.getEnvForType(taskInstance.__class__)
|
||||
DelayedTaskThread(taskInstance).start()
|
||||
|
||||
@@ -146,7 +147,13 @@ class DelayedTaskRunner:
|
||||
now = getSqlDatetime()
|
||||
exec_time = now + timedelta(seconds=delay)
|
||||
cls = instance.__class__
|
||||
|
||||
# Save "env" from delayed task, set it to None and restore it after save
|
||||
env = instance.env
|
||||
instance.env = None # type: ignore # clean env before saving pickle, save space (the env will be created again when executing)
|
||||
instanceDump = codecs.encode(pickle.dumps(instance), 'base64').decode()
|
||||
instance.env = env
|
||||
|
||||
typeName = str(cls.__module__ + '.' + cls.__name__)
|
||||
|
||||
logger.debug(
|
||||
|
@@ -66,6 +66,9 @@ from uds.web.util.errors import MAX_SERVICES_REACHED
|
||||
from .userservice import comms
|
||||
from .userservice.opchecker import UserServiceOpChecker
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
traceLogger = logging.getLogger('traceLog')
|
||||
|
||||
@@ -80,14 +83,12 @@ class UserServiceManager(metaclass=singleton.Singleton):
|
||||
UserServiceManager()
|
||||
) # Singleton pattern will return always the same instance
|
||||
|
||||
@staticmethod
|
||||
def getCacheStateFilter(servicePool: ServicePool, level: int) -> Q:
|
||||
return Q(cache_level=level) & UserServiceManager.getStateFilter(servicePool)
|
||||
def getCacheStateFilter(self, servicePool: ServicePool, level: int) -> Q:
|
||||
return Q(cache_level=level) & self.getStateFilter(servicePool.service)
|
||||
|
||||
@staticmethod
|
||||
def getStateFilter(servicePool: ServicePool) -> Q:
|
||||
def getStateFilter(self, service: 'models.Service') -> Q:
|
||||
if (
|
||||
servicePool.service.getInstance().maxDeployed == services.Service.UNLIMITED
|
||||
service.getInstance().maxDeployed == services.Service.UNLIMITED
|
||||
and GlobalConfig.MAX_SERVICES_COUNT_NEW.getBool() is False
|
||||
):
|
||||
states = [State.PREPARING, State.USABLE]
|
||||
@@ -95,23 +96,38 @@ class UserServiceManager(metaclass=singleton.Singleton):
|
||||
states = [State.PREPARING, State.USABLE, State.REMOVING, State.REMOVABLE]
|
||||
return Q(state__in=states)
|
||||
|
||||
def getExistingUserServices(self, service: 'models.Service') -> int:
|
||||
"""
|
||||
Returns the number of running user services for this service
|
||||
"""
|
||||
return UserService.objects.filter(
|
||||
self.getStateFilter(service) & Q(deployed_service__service=service)
|
||||
).count()
|
||||
|
||||
def maximumUserServicesDeployed(self, service: 'models.Service') -> bool:
|
||||
"""
|
||||
Checks if the maximum number of user services for this service has been reached
|
||||
"""
|
||||
serviceInstance = service.getInstance()
|
||||
# Early return, so no database count is needed
|
||||
if serviceInstance.maxDeployed == services.Service.UNLIMITED:
|
||||
return False
|
||||
|
||||
if self.getExistingUserServices(service) >= serviceInstance.maxDeployed:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def __checkMaxDeployedReached(self, servicePool: ServicePool) -> None:
|
||||
"""
|
||||
Checks if maxDeployed for the service has been reached, and, if so,
|
||||
raises an exception that no more services of this kind can be reached
|
||||
"""
|
||||
serviceInstance = servicePool.service.getInstance()
|
||||
# Early return, so no database count is needed
|
||||
if serviceInstance.maxDeployed == services.Service.UNLIMITED:
|
||||
return
|
||||
|
||||
numberOfServices = servicePool.userServices.filter(
|
||||
state__in=[State.PREPARING, State.USABLE]
|
||||
).count()
|
||||
|
||||
if serviceInstance.maxDeployed <= numberOfServices:
|
||||
if self.maximumUserServicesDeployed(servicePool.service):
|
||||
raise MaxServicesReachedError(
|
||||
'Max number of allowed deployments for service reached'
|
||||
_('Maximum number of user services reached for this {}').format(
|
||||
servicePool
|
||||
)
|
||||
)
|
||||
|
||||
def __createCacheAtDb(
|
||||
@@ -528,7 +544,7 @@ class UserServiceManager(metaclass=singleton.Singleton):
|
||||
if serviceType.usesCache:
|
||||
inAssigned = (
|
||||
servicePool.assignedUserServices()
|
||||
.filter(UserServiceManager.getStateFilter(servicePool))
|
||||
.filter(self.getStateFilter(servicePool.service))
|
||||
.count()
|
||||
)
|
||||
if (
|
||||
@@ -546,12 +562,14 @@ class UserServiceManager(metaclass=singleton.Singleton):
|
||||
events.addEvent(servicePool, events.ET_CACHE_MISS, fld1=0)
|
||||
return self.createAssignedFor(servicePool, user)
|
||||
|
||||
def getServicesInStateForProvider(self, provider_id: int, state: str) -> int:
|
||||
def getUserServicesInStatesForProvider(
|
||||
self, provider: 'models.Provider', states: typing.List[str]
|
||||
) -> int:
|
||||
"""
|
||||
Returns the number of services of a service provider in the state indicated
|
||||
"""
|
||||
return UserService.objects.filter(
|
||||
deployed_service__service__provider__id=provider_id, state=state
|
||||
deployed_service__service__provider=provider, state__in=states
|
||||
).count()
|
||||
|
||||
def canRemoveServiceFromDeployedService(self, servicePool: ServicePool) -> bool:
|
||||
@@ -559,8 +577,8 @@ class UserServiceManager(metaclass=singleton.Singleton):
|
||||
checks if we can do a "remove" from a deployed service
|
||||
serviceIsntance is just a helper, so if we already have unserialized deployedService
|
||||
"""
|
||||
removing = self.getServicesInStateForProvider(
|
||||
servicePool.service.provider.id, State.REMOVING
|
||||
removing = self.getUserServicesInStatesForProvider(
|
||||
servicePool.service.provider, [State.REMOVING]
|
||||
)
|
||||
serviceInstance = servicePool.service.getInstance()
|
||||
if (
|
||||
@@ -574,12 +592,12 @@ class UserServiceManager(metaclass=singleton.Singleton):
|
||||
"""
|
||||
Checks if we can start a new service
|
||||
"""
|
||||
preparing = self.getServicesInStateForProvider(
|
||||
servicePool.service.provider.id, State.PREPARING
|
||||
preparingForProvider = self.getUserServicesInStatesForProvider(
|
||||
servicePool.service.provider, [State.PREPARING]
|
||||
)
|
||||
serviceInstance = servicePool.service.getInstance()
|
||||
if (
|
||||
preparing >= serviceInstance.parent().getMaxPreparingServices()
|
||||
if self.maximumUserServicesDeployed(servicePool.service) or (
|
||||
preparingForProvider >= serviceInstance.parent().getMaxPreparingServices()
|
||||
and serviceInstance.parent().getIgnoreLimits() is False
|
||||
):
|
||||
return False
|
||||
@@ -918,7 +936,7 @@ class UserServiceManager(metaclass=singleton.Singleton):
|
||||
return metaId[0] == 'M'
|
||||
|
||||
def locateMetaService(
|
||||
self, user: User, idService: str, create: bool = False
|
||||
self, user: User, idService: str
|
||||
) -> typing.Optional[UserService]:
|
||||
kind, uuidMetapool = idService[0], idService[1:]
|
||||
if kind != 'M':
|
||||
|
44
server/src/uds/core/mfas/__init__.py
Normal file
44
server/src/uds/core/mfas/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- 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. 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.
|
||||
|
||||
"""
|
||||
UDS os managers related interfaces and classes
|
||||
|
||||
.. moduleauthor:: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
from .mfa import MFA
|
||||
|
||||
|
||||
def factory():
|
||||
"""
|
||||
Returns factory for register/access to authenticators
|
||||
"""
|
||||
from .mfafactory import MFAsFactory
|
||||
|
||||
return MFAsFactory.factory()
|
BIN
server/src/uds/core/mfas/mfa.png
Executable file
BIN
server/src/uds/core/mfas/mfa.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
215
server/src/uds/core/mfas/mfa.py
Normal file
215
server/src/uds/core/mfas/mfa.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# -*- 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 datetime
|
||||
import random
|
||||
import enum
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import ugettext_noop as _
|
||||
from uds.models import getSqlDatetime
|
||||
from uds.core import Module
|
||||
from uds.core.auths import exceptions
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util.request import ExtendedHttpRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MFA(Module):
|
||||
"""
|
||||
this class provides an abstraction of a Multi Factor Authentication
|
||||
"""
|
||||
|
||||
# informational related data
|
||||
# : Name of type, used at administration interface to identify this
|
||||
# : notifier type (e.g. "Email", "SMS", etc.)
|
||||
# : This string will be translated when provided to admin interface
|
||||
# : using gettext, so you can mark it as "_" at derived classes (using gettext_noop)
|
||||
# : if you want so it can be translated.
|
||||
typeName: typing.ClassVar[str] = _('Base MFA')
|
||||
|
||||
# : Name of type used by Managers to identify this type of service
|
||||
# : We could have used here the Class name, but we decided that the
|
||||
# : module implementator will be the one that will provide a name that
|
||||
# : will relation the class (type) and that name.
|
||||
typeType: typing.ClassVar[str] = 'baseMFA'
|
||||
|
||||
# : Description shown at administration level for this authenticator.
|
||||
# : This string will be translated when provided to admin interface
|
||||
# : using gettext, so you can mark it as "_" at derived classes (using gettext_noop)
|
||||
# : if you want so it can be translated.
|
||||
typeDescription: typing.ClassVar[str] = _('Base MFA')
|
||||
|
||||
# : Icon file, used to represent this authenticator at administration interface
|
||||
# : This file should be at same folder as this class is, except if you provide
|
||||
# : your own :py:meth:uds.core.module.BaseModule.icon method.
|
||||
iconFile: typing.ClassVar[str] = 'mfa.png'
|
||||
|
||||
# : Cache time for the generated MFA code
|
||||
# : this means that the code will be valid for this time, and will not
|
||||
# : be resent to the user until the time expires.
|
||||
# : This value is in minutes
|
||||
# : Note: This value is used by default "process" methos, but you can
|
||||
# : override it in your own implementation.
|
||||
cacheTime: typing.ClassVar[int] = 5
|
||||
|
||||
class RESULT(enum.IntEnum):
|
||||
"""
|
||||
This enum is used to know if the MFA code was sent or not.
|
||||
"""
|
||||
OK = 1
|
||||
ALLOWED = 2
|
||||
|
||||
def __init__(self, environment: 'Environment', values: Module.ValuesType):
|
||||
super().__init__(environment, values)
|
||||
self.initialize(values)
|
||||
|
||||
def initialize(self, values: Module.ValuesType) -> None:
|
||||
"""
|
||||
This method will be invoked from __init__ constructor.
|
||||
This is provided so you don't have to provide your own __init__ method,
|
||||
and invoke base methods.
|
||||
This will get invoked when all initialization stuff is done
|
||||
|
||||
Args:
|
||||
values: If values is not none, this object is being initialized
|
||||
from administration interface, and not unmarshal will be done.
|
||||
If it's None, this is initialized internally, and unmarshal will
|
||||
be called after this.
|
||||
|
||||
Default implementation does nothing
|
||||
"""
|
||||
|
||||
def label(self) -> str:
|
||||
"""
|
||||
This method will be invoked from the MFA form, to know the human name of the field
|
||||
that will be used to enter the MFA code.
|
||||
"""
|
||||
return 'MFA Code'
|
||||
|
||||
def html(self, request: 'ExtendedHttpRequest') -> str:
|
||||
"""
|
||||
This method will be invoked from the MFA form, to know the HTML that will be presented
|
||||
to the user below the MFA code form.
|
||||
"""
|
||||
return ''
|
||||
|
||||
def validity(self) -> int:
|
||||
"""
|
||||
This method will be invoked from the MFA form, to know the validity in secods
|
||||
of the MFA code.
|
||||
If value is 0 or less, means the code is always valid.
|
||||
"""
|
||||
return self.cacheTime
|
||||
|
||||
def emptyIndentifierAllowedToLogin(self, request: 'ExtendedHttpRequest') -> bool:
|
||||
"""
|
||||
If this method returns True, an user that has no "identifier" is allowed to login without MFA
|
||||
"""
|
||||
return True
|
||||
|
||||
def sendCode(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, code: str) -> 'MFA.RESULT':
|
||||
"""
|
||||
This method will be invoked from "process" method, to send the MFA code to the user.
|
||||
If returns MFA.RESULT.OK, the MFA code was sent.
|
||||
If returns MFA.RESULT.ALLOW, the MFA code was not sent, the user does not need to enter the MFA code.
|
||||
If raises an error, the MFA code was not sent, and the user needs to enter the MFA code.
|
||||
"""
|
||||
|
||||
raise NotImplementedError('sendCode method not implemented')
|
||||
|
||||
def process(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, validity: typing.Optional[int] = None) -> 'MFA.RESULT':
|
||||
"""
|
||||
This method will be invoked from the MFA form, to send the MFA code to the user.
|
||||
The identifier where to send the code, will be obtained from "mfaIdentifier" method.
|
||||
Default implementation generates a random code and sends invokes "sendCode" method.
|
||||
|
||||
If returns MFA.RESULT.OK, the MFA code was sent.
|
||||
If returns MFA.RESULT.ALLOW, the MFA code was not sent, the user does not need to enter the MFA code.
|
||||
If raises an error, the MFA code was not sent, and the user needs to enter the MFA code.
|
||||
"""
|
||||
# try to get the stored code
|
||||
storageKey = request.ip + userId
|
||||
data: typing.Any = self.storage.getPickle(storageKey)
|
||||
validity = validity if validity is not None else self.validity() * 60
|
||||
try:
|
||||
if data and validity:
|
||||
# if we have a stored code, check if it's still valid
|
||||
if data[0] + datetime.timedelta(seconds=validity) > getSqlDatetime():
|
||||
# if it's still valid, just return without sending a new one
|
||||
return MFA.RESULT.OK
|
||||
except Exception:
|
||||
# if we have a problem, just remove the stored code
|
||||
self.storage.remove(storageKey)
|
||||
|
||||
# Generate a 6 digit code (0-9)
|
||||
code = ''.join(random.SystemRandom().choices('0123456789', k=6))
|
||||
logger.debug('Generated OTP is %s', code)
|
||||
# Store the code in the database, own storage space
|
||||
self.storage.putPickle(storageKey, (getSqlDatetime(), code))
|
||||
# Send the code to the user
|
||||
return self.sendCode(request, userId, username, identifier, code)
|
||||
|
||||
|
||||
def validate(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, code: str, validity: typing.Optional[int] = None) -> None:
|
||||
"""
|
||||
If this method is provided by an authenticator, the user will be allowed to enter a MFA code
|
||||
You must raise an "exceptions.MFAError" if the code is not valid.
|
||||
"""
|
||||
# Validate the code
|
||||
try:
|
||||
err = _('Invalid MFA code')
|
||||
|
||||
storageKey = request.ip + userId
|
||||
data = self.storage.getPickle(storageKey)
|
||||
if data and len(data) == 2:
|
||||
validity = validity if validity is not None else self.validity() * 60
|
||||
if validity > 0 and data[0] + datetime.timedelta(seconds=validity) < getSqlDatetime():
|
||||
# if it is no more valid, raise an error
|
||||
# Remove stored code and raise error
|
||||
self.storage.remove(storageKey)
|
||||
raise exceptions.MFAError('MFA Code expired')
|
||||
|
||||
# Check if the code is valid
|
||||
if data[1] == code:
|
||||
# Code is valid, remove it from storage
|
||||
self.storage.remove(storageKey)
|
||||
return
|
||||
except Exception as e:
|
||||
# Any error means invalid code
|
||||
err = str(e)
|
||||
|
||||
raise exceptions.MFAError(err)
|
||||
|
61
server/src/uds/core/mfas/mfafactory.py
Normal file
61
server/src/uds/core/mfas/mfafactory.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# -*- 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
|
||||
|
||||
from uds.core.util import singleton
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .mfa import MFA
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MFAsFactory(metaclass=singleton.Singleton):
|
||||
_factory: typing.Optional['MFAsFactory'] = None
|
||||
_mfas: typing.MutableMapping[str, typing.Type['MFA']] = {}
|
||||
|
||||
@staticmethod
|
||||
def factory() -> 'MFAsFactory':
|
||||
return MFAsFactory()
|
||||
|
||||
def providers(self) -> typing.Mapping[str, typing.Type['MFA']]:
|
||||
return self._mfas
|
||||
|
||||
def insert(self, type_: typing.Type['MFA']) -> None:
|
||||
logger.debug('Adding Multi Factor Auth %s as %s', type_.type(), type_)
|
||||
typeName = type_.type().lower()
|
||||
self._mfas[typeName] = type_
|
||||
|
||||
def lookup(self, typeName: str) -> typing.Optional[typing.Type['MFA']]:
|
||||
return self._mfas.get(typeName.lower(), None)
|
@@ -181,7 +181,7 @@ class Module(UserInterface, Environmentable, Serializable):
|
||||
'iconFile' class attribute
|
||||
"""
|
||||
file_ = open(
|
||||
os.path.dirname(sys.modules[cls.__module__].__file__) + '/' + cls.iconFile,
|
||||
os.path.dirname(typing.cast(str, sys.modules[cls.__module__].__file__)) + '/' + cls.iconFile,
|
||||
'rb',
|
||||
)
|
||||
data = file_.read()
|
||||
|
@@ -243,15 +243,33 @@ class Service(Module):
|
||||
"""
|
||||
return self._provider
|
||||
|
||||
def unmarshal(self, data: bytes) -> None:
|
||||
# In fact, we will not unmarshall anything here, but setup maxDeployed
|
||||
# if maxServices exists and it is a gui.NumericField
|
||||
# Invoke base unmarshall, so "gui fields" gets loaded from data
|
||||
super().unmarshal(data)
|
||||
|
||||
if hasattr(self, 'maxServices'):
|
||||
# Fix Own "maxDeployed" value after loading fields
|
||||
try:
|
||||
self.maxDeployed = getattr(self, 'maxServices').num()
|
||||
except Exception:
|
||||
self.maxDeployed = Service.UNLIMITED
|
||||
|
||||
if self.maxDeployed < 1:
|
||||
self.maxDeployed = Service.UNLIMITED
|
||||
|
||||
# Keep untouched if maxServices is not present
|
||||
|
||||
def requestServicesForAssignation(
|
||||
self, **kwargs
|
||||
) -> typing.Iterable[UserDeployment]:
|
||||
"""
|
||||
override this if mustAssignManualy is True
|
||||
@params kwargs: Named arguments
|
||||
@return an array with the services that we can assign (they must be of type deployedType)
|
||||
We will access the returned array in "name" basis. This means that the service will be assigned by "name", so be care that every single service
|
||||
returned are not repeated... :-)
|
||||
@return an iterable with the services that we can assign manually (they must be of type UserDeployment)
|
||||
We will access the returned iterable in "name" basis. This means that the service will be assigned by "name", so be care that every single service
|
||||
name returned is unique :-)
|
||||
"""
|
||||
raise Exception(
|
||||
'The class {0} has been marked as manually asignable but no requestServicesForAssignetion provided!!!'.format(
|
||||
|
@@ -160,7 +160,7 @@ class UserDeployment(
|
||||
self._uuid = kwargs.get('uuid', '')
|
||||
# If it has dbService, got uuid from it
|
||||
if self._dbService:
|
||||
self._uuid = self._dbService.uuid
|
||||
self._uuid = self._dbService.uuid or ''
|
||||
|
||||
self.initialize()
|
||||
|
||||
|
@@ -30,6 +30,8 @@
|
||||
"""
|
||||
@author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import codecs
|
||||
import logging
|
||||
import typing
|
||||
@@ -267,6 +269,33 @@ class Transport(Module):
|
||||
params,
|
||||
)
|
||||
|
||||
def getRelativeScript(
|
||||
self, scriptName: str, params: typing.Mapping[str, typing.Any]
|
||||
) -> typing.Tuple[str, str, typing.Mapping[str, typing.Any]]:
|
||||
"""Returns a script that will be executed on client, but will be downloaded from server
|
||||
|
||||
Args:
|
||||
scriptName: Name of the script to be downloaded, relative path (i.e. 'scripts/direct/transport.py')
|
||||
params: Parameters for the return tuple
|
||||
"""
|
||||
# Reads script and signature
|
||||
basePath = os.path.dirname(sys.modules[self.__module__].__file__ or 'not_found') # Will raise if not found
|
||||
|
||||
script = open(os.path.join(basePath, scriptName), 'r').read()
|
||||
signature = open(os.path.join(basePath, scriptName + '.signature'), 'r').read()
|
||||
|
||||
return script, signature, params
|
||||
|
||||
def getScript(
|
||||
self, osName: str, type: typing.Literal['tunnel', 'direct'], params: typing.Mapping[str, typing.Any]
|
||||
) -> typing.Tuple[str, str, typing.Mapping[str, typing.Any]]:
|
||||
"""
|
||||
Returns a script for the given os and type
|
||||
"""
|
||||
return self.getRelativeScript(
|
||||
'scripts/{os}/{type}.py'.format(os=osName, type=type), params
|
||||
)
|
||||
|
||||
def getLink(
|
||||
self,
|
||||
userService: 'models.UserService',
|
||||
|
@@ -37,15 +37,17 @@ import pickle
|
||||
import copy
|
||||
import typing
|
||||
import logging
|
||||
from collections import abc
|
||||
|
||||
from django.utils.translation import get_language, ugettext as _, ugettext_noop
|
||||
from django.conf import settings
|
||||
|
||||
from uds.core.managers import cryptoManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UDSB = b'udsprotect'
|
||||
|
||||
UDSB = b'udsprotect' # UDS base key, old
|
||||
UDSK = settings.SECRET_KEY[8:24].encode() # UDS key, new
|
||||
|
||||
class gui:
|
||||
"""
|
||||
@@ -101,6 +103,7 @@ class gui:
|
||||
CREDENTIALS_TAB: typing.ClassVar[str] = ugettext_noop('Credentials')
|
||||
TUNNEL_TAB: typing.ClassVar[str] = ugettext_noop('Tunnel')
|
||||
DISPLAY_TAB: typing.ClassVar[str] = ugettext_noop('Display')
|
||||
MFA_TAB: typing.ClassVar[str] = ugettext_noop('MFA')
|
||||
|
||||
# : Static Callbacks simple registry
|
||||
callbacks: typing.Dict[
|
||||
@@ -111,17 +114,30 @@ class gui:
|
||||
# Helpers
|
||||
@staticmethod
|
||||
def convertToChoices(
|
||||
vals: typing.Union[typing.List[str], typing.MutableMapping[str, str]]
|
||||
vals: typing.Union[typing.Iterable[typing.Union[str, typing.Dict[str, str]]], typing.Dict[str, str]]
|
||||
) -> typing.List[typing.Dict[str, str]]:
|
||||
"""
|
||||
Helper to convert from array of strings to the same dict used in choice,
|
||||
Helper to convert from array of strings (or dictionaries) to the same dict used in choice,
|
||||
multichoice, ..
|
||||
"""
|
||||
if isinstance(vals, (list, tuple)):
|
||||
return [{'id': v, 'text': v} for v in vals]
|
||||
if not vals:
|
||||
return []
|
||||
# Helper to convert an item to a dict
|
||||
def choiceFromValue(val: typing.Union[str, typing.Dict[str, str]]) -> typing.Dict[str, str]:
|
||||
if isinstance(val, str):
|
||||
return {'id': val, 'text': val}
|
||||
return copy.deepcopy(val)
|
||||
|
||||
# Dictionary
|
||||
return [{'id': k, 'text': v} for k, v in vals.items()]
|
||||
# If is a dict
|
||||
if isinstance(vals, abc.Mapping):
|
||||
return [{'id': str(k), 'text': v} for k, v in vals.items()]
|
||||
|
||||
# If is an iterator
|
||||
if isinstance(vals, abc.Iterable):
|
||||
return [choiceFromValue(v) for v in vals]
|
||||
|
||||
|
||||
raise ValueError('Invalid type for convertToChoices: {}'.format(type(vals)))
|
||||
|
||||
@staticmethod
|
||||
def convertToList(vals: typing.Iterable[str]) -> typing.List[str]:
|
||||
@@ -252,7 +268,7 @@ class gui:
|
||||
|
||||
def __init__(self, **options) -> None:
|
||||
# Added defaultValue as alias for defvalue
|
||||
defvalue = options.get('defvalue', options.get('defaultValue', ''))
|
||||
defvalue = options.get('defvalue', options.get('defaultValue', options.get('defValue', '')))
|
||||
if callable(defvalue):
|
||||
defvalue = defvalue()
|
||||
self._data = {
|
||||
@@ -533,7 +549,7 @@ class gui:
|
||||
except Exception:
|
||||
return datetime.date.min if min else datetime.date.max
|
||||
|
||||
def datetime(self, min: bool) -> datetime.datetime:
|
||||
def datetime(self, min: bool = True) -> datetime.datetime:
|
||||
"""
|
||||
Returns the date this object represents
|
||||
|
||||
@@ -767,7 +783,8 @@ class gui:
|
||||
|
||||
def __init__(self, **options):
|
||||
super().__init__(**options)
|
||||
if options.get('values') and isinstance(options.get('values'), dict):
|
||||
vals = options.get('values')
|
||||
if vals and isinstance(vals, (dict, list, tuple)):
|
||||
options['values'] = gui.convertToChoices(options['values'])
|
||||
self._data['values'] = options.get('values', [])
|
||||
if 'fills' in options:
|
||||
@@ -779,7 +796,7 @@ class gui:
|
||||
gui.callbacks[fills['callbackName']] = fnc
|
||||
self._type(gui.InputField.CHOICE_TYPE)
|
||||
|
||||
def setValues(self, values: typing.List[typing.Any]):
|
||||
def setValues(self, values: typing.List[typing.Dict[str, typing.Any]]):
|
||||
"""
|
||||
Set the values for this choice field
|
||||
"""
|
||||
@@ -989,6 +1006,12 @@ class UserInterface(metaclass=UserInterfaceType):
|
||||
of this posibility in a near version...
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def initClassGui(cls) -> None:
|
||||
"""
|
||||
This method is used to initialize the gui fields of the class.
|
||||
"""
|
||||
|
||||
def valuesDict(self) -> gui.ValuesDictType:
|
||||
"""
|
||||
Returns own data needed for user interaction as a dict of key-names ->
|
||||
@@ -1058,8 +1081,9 @@ class UserInterface(metaclass=UserInterfaceType):
|
||||
# logger.debug('Serializing value {0}'.format(v.value))
|
||||
val = b'\001' + pickle.dumps(v.value, protocol=0)
|
||||
elif v.isType(gui.InfoField.PASSWORD_TYPE):
|
||||
val = b'\004' + cryptoManager().AESCrypt(
|
||||
v.value.encode('utf8'), UDSB, True
|
||||
# Old \004 field type is not used anymore, is for old "udsprotect" encryption
|
||||
val = b'\005' + cryptoManager().AESCrypt(
|
||||
v.value.encode('utf8'), UDSK, True
|
||||
)
|
||||
elif v.isType(gui.InputField.NUMERIC_TYPE):
|
||||
val = str(int(v.num())).encode('utf8')
|
||||
@@ -1110,6 +1134,8 @@ class UserInterface(metaclass=UserInterfaceType):
|
||||
val = pickle.loads(v[1:])
|
||||
elif v and v[0] == 4:
|
||||
val = cryptoManager().AESDecrypt(v[1:], UDSB, True).decode()
|
||||
elif v and v[0] == 5:
|
||||
val = cryptoManager().AESDecrypt(v[1:], UDSK, True).decode()
|
||||
else:
|
||||
val = v
|
||||
# Ensure "legacy bytes" values are loaded correctly as unicode
|
||||
@@ -1143,6 +1169,8 @@ class UserInterface(metaclass=UserInterfaceType):
|
||||
if obj:
|
||||
obj.initGui() # We give the "oportunity" to fill necesary theGui data before providing it to client
|
||||
theGui = obj
|
||||
else:
|
||||
cls.initClassGui() # We give the "oportunity" to fill necesary theGui data before providing it to client
|
||||
|
||||
res: typing.List[typing.MutableMapping[str, typing.Any]] = []
|
||||
|
||||
|
@@ -31,6 +31,7 @@
|
||||
.. moduleauthor:: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import time
|
||||
import typing
|
||||
import logging
|
||||
@@ -53,21 +54,22 @@ ONE_DAY = 3600 * 24
|
||||
|
||||
|
||||
class CalendarChecker:
|
||||
__slots__ = ('calendar',)
|
||||
|
||||
calendar: Calendar
|
||||
|
||||
# For performance checking
|
||||
updates: int = 0
|
||||
cache_hit: int = 0
|
||||
hits: int = 0
|
||||
updates: typing.ClassVar[int] = 0
|
||||
cache_hit: typing.ClassVar[int] = 0
|
||||
hits: typing.ClassVar[int] = 0
|
||||
|
||||
cache = Cache('calChecker')
|
||||
cache: typing.ClassVar[Cache] = Cache('calChecker')
|
||||
|
||||
def __init__(self, calendar: Calendar) -> None:
|
||||
self.calendar = calendar
|
||||
|
||||
def _updateData(self, dtime: datetime.datetime) -> bitarray.bitarray:
|
||||
logger.debug('Updating %s', dtime)
|
||||
# Else, update the array
|
||||
CalendarChecker.updates += 1
|
||||
|
||||
data = bitarray.bitarray(60 * 24) # Granurality is minute
|
||||
@@ -123,7 +125,9 @@ class CalendarChecker:
|
||||
|
||||
return data
|
||||
|
||||
def _updateEvents(self, checkFrom, startEvent=True):
|
||||
def _updateEvents(
|
||||
self, checkFrom: datetime.datetime, startEvent: bool = True
|
||||
) -> typing.Optional[datetime.datetime]:
|
||||
next_event = None
|
||||
for rule in self.calendar.rules.all():
|
||||
# logger.debug('RULE: start = {}, checkFrom = {}, end'.format(rule.start.date(), checkFrom.date()))
|
||||
@@ -140,7 +144,7 @@ class CalendarChecker:
|
||||
|
||||
return next_event
|
||||
|
||||
def check(self, dtime=None) -> bool:
|
||||
def check(self, dtime: typing.Optional[datetime.datetime] = None) -> bool:
|
||||
"""
|
||||
Checks if the given time is a valid event on calendar
|
||||
@param dtime: Datetime object to check
|
||||
@@ -152,10 +156,10 @@ class CalendarChecker:
|
||||
memCache = caches['memory']
|
||||
|
||||
# First, try to get data from cache if it is valid
|
||||
cacheKey = (
|
||||
str(self.calendar.modified.toordinal())
|
||||
+ str(dtime.date().toordinal())
|
||||
+ self.calendar.uuid
|
||||
cacheKey = CalendarChecker._cacheKey(
|
||||
str(self.calendar.modified)
|
||||
+ str(dtime.date())
|
||||
+ (self.calendar.uuid or '')
|
||||
+ 'checker'
|
||||
)
|
||||
# First, check "local memory cache", and if not found, from DB cache
|
||||
@@ -180,33 +184,38 @@ class CalendarChecker:
|
||||
return bool(data[dtime.hour * 60 + dtime.minute])
|
||||
|
||||
def nextEvent(
|
||||
self, checkFrom=None, startEvent=True, offset=None
|
||||
self,
|
||||
checkFrom: typing.Optional[datetime.datetime] = None,
|
||||
startEvent: bool = True,
|
||||
offset: typing.Optional[datetime.timedelta] = None,
|
||||
) -> typing.Optional[datetime.datetime]:
|
||||
"""
|
||||
Returns next event for this interval
|
||||
"""
|
||||
logger.debug('Obtaining nextEvent')
|
||||
if checkFrom is None:
|
||||
if not checkFrom:
|
||||
checkFrom = getSqlDatetime()
|
||||
|
||||
if offset is None:
|
||||
if not offset:
|
||||
offset = datetime.timedelta(minutes=0)
|
||||
|
||||
cacheKey = (
|
||||
str(hash(self.calendar.modified))
|
||||
+ self.calendar.uuid
|
||||
cacheKey = CalendarChecker._cacheKey(
|
||||
str(self.calendar.modified)
|
||||
+ (self.calendar.uuid or '')
|
||||
+ str(offset.seconds)
|
||||
+ str(int(time.mktime(checkFrom.timetuple())))
|
||||
+ str(checkFrom)
|
||||
+ 'event'
|
||||
+ ('x' if startEvent else '_')
|
||||
)
|
||||
next_event = CalendarChecker.cache.get(cacheKey, None)
|
||||
if next_event is None:
|
||||
next_event: typing.Optional[datetime.datetime] = CalendarChecker.cache.get(
|
||||
cacheKey, None
|
||||
)
|
||||
if not next_event:
|
||||
logger.debug('Regenerating cached nextEvent')
|
||||
next_event = self._updateEvents(
|
||||
checkFrom + offset, startEvent
|
||||
) # We substract on checkin, so we can take into account for next execution the "offset" on start & end (just the inverse of current, so we substract it)
|
||||
if next_event is not None:
|
||||
if next_event:
|
||||
next_event += offset
|
||||
CalendarChecker.cache.put(cacheKey, next_event, 3600)
|
||||
else:
|
||||
@@ -217,3 +226,12 @@ class CalendarChecker:
|
||||
|
||||
def debug(self) -> str:
|
||||
return "Calendar checker for {}".format(self.calendar)
|
||||
|
||||
@staticmethod
|
||||
def _cacheKey(key: str) -> str:
|
||||
# Returns a valid cache key for all caching backends (memcached, redis, or whatever)
|
||||
# Simple, fastest algorihm is to use md5
|
||||
h = hashlib.md5()
|
||||
h.update(key.encode('utf-8'))
|
||||
return h.hexdigest()
|
||||
|
||||
|
@@ -52,6 +52,27 @@ _getLater = []
|
||||
_configParams = {}
|
||||
|
||||
|
||||
# Pair of section/value removed from current UDS version
|
||||
REMOVED_CONFIG_ELEMENTS = {
|
||||
'UDS': (
|
||||
'allowPreferencesAccess',
|
||||
'customHtmlLogin',
|
||||
'UDS Theme',
|
||||
'UDS Theme Enhaced',
|
||||
'css',
|
||||
'allowPreferencesAccess',
|
||||
'loginUrl',
|
||||
'maxLoginTries',
|
||||
'loginBlockTime',
|
||||
),
|
||||
'Cluster': ('Destination CPU Load', 'Migration CPU Load', 'Migration Free Memory'),
|
||||
'IPAUTH': ('autoLogin',),
|
||||
'VMWare': ('minUsableDatastoreGB', 'maxRetriesOnError'),
|
||||
'HyperV': ('minUsableDatastoreGB',),
|
||||
'Security': ('adminIdleTime', 'userSessionLength'),
|
||||
}
|
||||
|
||||
|
||||
class Config:
|
||||
# Fields types, so inputs get more "beautiful"
|
||||
TEXT_FIELD: int = 0
|
||||
@@ -119,7 +140,11 @@ class Config:
|
||||
self.set(self._default)
|
||||
self._data = self._default
|
||||
except Exception as e:
|
||||
logger.info('Error accessing db config {0}.{1}'.format(self._section.name(), self._key))
|
||||
logger.info(
|
||||
'Error accessing db config {0}.{1}'.format(
|
||||
self._section.name(), self._key
|
||||
)
|
||||
)
|
||||
logger.exception(e)
|
||||
self._data = self._default
|
||||
|
||||
@@ -276,6 +301,36 @@ class Config:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def getConfigValues(
|
||||
addCrypt: bool = False,
|
||||
) -> typing.Mapping[str, typing.Mapping[str, typing.Mapping[str, typing.Any]]]:
|
||||
"""
|
||||
Returns a dictionary with all config values
|
||||
"""
|
||||
res: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
|
||||
for cfg in Config.enumerate():
|
||||
# Skip removed configuration values, even if they are in database
|
||||
logger.debug('Key: %s, val: %s', cfg.section(), cfg.key())
|
||||
if cfg.key() in REMOVED_CONFIG_ELEMENTS.get(cfg.section(), ()):
|
||||
continue
|
||||
|
||||
if cfg.isCrypted() is True and addCrypt is False:
|
||||
continue
|
||||
|
||||
# add section if it do not exists
|
||||
if cfg.section() not in res:
|
||||
res[cfg.section()] = {}
|
||||
res[cfg.section()][cfg.key()] = {
|
||||
'value': cfg.get(),
|
||||
'crypt': cfg.isCrypted(),
|
||||
'longText': cfg.isLongText(),
|
||||
'type': cfg.getType(),
|
||||
'params': cfg.getParams(),
|
||||
}
|
||||
logger.debug('Configuration: %s', res)
|
||||
return res
|
||||
|
||||
|
||||
class GlobalConfig:
|
||||
"""
|
||||
@@ -345,6 +400,10 @@ class GlobalConfig:
|
||||
ENHANCED_SECURITY: Config.Value = Config.section(SECURITY_SECTION).value(
|
||||
'Enable Enhanced Security', '1', type=Config.BOOLEAN_FIELD
|
||||
)
|
||||
# Paranoid security
|
||||
ENFORCE_ZERO_TRUST: Config.Value = Config.section(SECURITY_SECTION).value(
|
||||
'Enforze Zero-Trust Mode', '0', type=Config.BOOLEAN_FIELD
|
||||
)
|
||||
# Time an admi session can be idle before being "logged out"
|
||||
# ADMIN_IDLE_TIME: Config.Value = Config.section(SECURITY_SECTION).value('adminIdleTime', '14400', type=Config.NUMERIC_FIELD) # Defaults to 4 hous
|
||||
# Time betwen checks of unused services by os managers
|
||||
@@ -419,6 +478,11 @@ class GlobalConfig:
|
||||
'New Max restriction', '0', type=Config.BOOLEAN_FIELD
|
||||
)
|
||||
|
||||
# Maximum security logs duration in days
|
||||
MAX_AUDIT_LOGS_DURATION: Config.Value = Config.section(SECURITY_SECTION).value(
|
||||
'Max Audit Logs duration', '365', type=Config.NUMERIC_FIELD
|
||||
)
|
||||
|
||||
# Allowed "trusted sources" for request
|
||||
TRUSTED_SOURCES: Config.Value = Config.section(SECURITY_SECTION).value(
|
||||
'Trusted Hosts', '*', type=Config.TEXT_FIELD
|
||||
|
@@ -34,6 +34,7 @@ from functools import wraps
|
||||
import logging
|
||||
import inspect
|
||||
import typing
|
||||
import threading
|
||||
|
||||
from uds.core.util.html import checkBrowser
|
||||
from uds.web.util import errors
|
||||
@@ -188,3 +189,14 @@ def allowCache(
|
||||
return wrapper
|
||||
|
||||
return allowCacheDecorator
|
||||
|
||||
# Decorator to execute method in a thread
|
||||
def threaded(func: typing.Callable[..., None]) -> typing.Callable[..., None]:
|
||||
"""Decorator to execute method in a thread"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> None:
|
||||
thread = threading.Thread(target=func, args=args, kwargs=kwargs)
|
||||
thread.start()
|
||||
|
||||
return wrapper
|
@@ -43,12 +43,15 @@ logger = logging.getLogger(__name__)
|
||||
useLogger = logging.getLogger('useLog')
|
||||
|
||||
# Logging levels
|
||||
OTHER, DEBUG, INFO, WARN, ERROR, FATAL = (
|
||||
OTHER, DEBUG, INFO, WARNING, ERROR, CRITICAL = (
|
||||
10000 * (x + 1) for x in range(6)
|
||||
) # @UndefinedVariable
|
||||
|
||||
WARN = WARNING
|
||||
FATAL = CRITICAL
|
||||
|
||||
# Logging sources
|
||||
INTERNAL, ACTOR, TRANSPORT, OSMANAGER, UNKNOWN, WEB, ADMIN, SERVICE = (
|
||||
INTERNAL, ACTOR, TRANSPORT, OSMANAGER, UNKNOWN, WEB, ADMIN, SERVICE, REST = (
|
||||
'internal',
|
||||
'actor',
|
||||
'transport',
|
||||
@@ -57,6 +60,7 @@ INTERNAL, ACTOR, TRANSPORT, OSMANAGER, UNKNOWN, WEB, ADMIN, SERVICE = (
|
||||
'web',
|
||||
'admin',
|
||||
'service',
|
||||
'rest',
|
||||
)
|
||||
|
||||
OTHERSTR, DEBUGSTR, INFOSTR, WARNSTR, ERRORSTR, FATALSTR = (
|
||||
@@ -81,6 +85,10 @@ __nameLevels = {
|
||||
# Reverse dict of names
|
||||
__valueLevels = {v: k for k, v in __nameLevels.items()}
|
||||
|
||||
# Global log owner types:
|
||||
OWNER_TYPE_GLOBAL = -1
|
||||
OWNER_TYPE_AUDIT = -2
|
||||
|
||||
|
||||
def logLevelFromStr(level: str) -> int:
|
||||
"""
|
||||
|
@@ -37,7 +37,7 @@ from django.utils import timezone
|
||||
|
||||
from uds.core.util import os_detector as OsDetector
|
||||
from uds.core.util.config import GlobalConfig
|
||||
from uds.core.auths.auth import EXPIRY_KEY, ROOT_ID, USER_KEY, getRootUser, webLogout
|
||||
from uds.core.auths.auth import AUTHORIZED_KEY, EXPIRY_KEY, ROOT_ID, USER_KEY, getRootUser, webLogout
|
||||
from uds.core.util.request import (
|
||||
setRequest,
|
||||
delCurrentRequest,
|
||||
@@ -70,6 +70,7 @@ class GlobalRequestMiddleware:
|
||||
def __call__(self, request: ExtendedHttpRequest):
|
||||
# Add IP to request
|
||||
GlobalRequestMiddleware.fillIps(request)
|
||||
request.authorized = request.session.get(AUTHORIZED_KEY, False)
|
||||
|
||||
# Store request on cache
|
||||
setRequest(request=request)
|
||||
@@ -103,6 +104,10 @@ class GlobalRequestMiddleware:
|
||||
|
||||
response = self._get_response(request)
|
||||
|
||||
# Update authorized on session
|
||||
if hasattr(request, 'session'):
|
||||
request.session[AUTHORIZED_KEY] = request.authorized
|
||||
|
||||
return self._process_response(request, response)
|
||||
|
||||
@staticmethod
|
||||
|
@@ -49,7 +49,9 @@ class ExtendedHttpRequest(HttpRequest):
|
||||
ip: str
|
||||
ip_proxy: str
|
||||
os: DictAsObj
|
||||
user: typing.Optional[User] # type: ignore
|
||||
user: typing.Optional[User]
|
||||
authorized: bool
|
||||
|
||||
|
||||
|
||||
class ExtendedHttpRequestWithUser(ExtendedHttpRequest):
|
||||
|
@@ -178,6 +178,9 @@ class StorageAsDict(MutableMapping):
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
return self[key] or default
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self.__delitem__(key)
|
||||
|
||||
# Custom utility methods
|
||||
@property
|
||||
def group(self) -> str:
|
||||
|
@@ -130,7 +130,7 @@ def packageRelativeFile(moduleName: str, fileName: str) -> str:
|
||||
This allows to keep images alongside report
|
||||
"""
|
||||
mod = sys.modules[moduleName]
|
||||
if mod and mod.__file__:
|
||||
if mod and hasattr(mod, '__file__') and mod.__file__:
|
||||
pkgpath = os.path.dirname(mod.__file__)
|
||||
return os.path.join(pkgpath, fileName)
|
||||
# Not found, return fileName
|
||||
@@ -189,6 +189,14 @@ def checkValidBasename(baseName: str, length: int = -1) -> None:
|
||||
ugettext('The machine name can\'t be only numbers')
|
||||
)
|
||||
|
||||
|
||||
def removeControlCharacters(s: str) -> str:
|
||||
"""
|
||||
Removes control characters from an unicode string
|
||||
|
||||
Arguments:
|
||||
s {str} -- string to remove control characters from
|
||||
|
||||
Returns:
|
||||
str -- string without control characters
|
||||
"""
|
||||
return ''.join(ch for ch in s if unicodedata.category(ch)[0] != "C")
|
||||
|
@@ -116,6 +116,22 @@ def validatePort(portStr: str) -> int:
|
||||
return validateNumeric(portStr, minValue=0, maxValue=65535, fieldName='Port')
|
||||
|
||||
|
||||
def validateHostPortPair(hostPortPair: str) -> typing.Tuple[str, int]:
|
||||
"""
|
||||
Validates that a host:port pair is valid
|
||||
:param hostPortPair: host:port pair to validate
|
||||
:param returnAsInteger: if True, returns value as integer, if not, as string
|
||||
:return: Raises Module.Validation exception if is invalid, else return the value "fixed"
|
||||
"""
|
||||
try:
|
||||
host, port = hostPortPair.split(':')
|
||||
return validateHostname(host, 255, False), validatePort(port)
|
||||
except Exception:
|
||||
raise Module.ValidationException(
|
||||
_('{} is not a valid host:port pair').format(hostPortPair)
|
||||
)
|
||||
|
||||
|
||||
def validateTimeout(timeOutStr: str) -> int:
|
||||
"""
|
||||
Validates that a timeout value is valid
|
||||
@@ -154,3 +170,21 @@ def validateMacRange(macRange: str) -> str:
|
||||
)
|
||||
|
||||
return macRange
|
||||
|
||||
def validateEmail(email: str) -> str:
|
||||
"""
|
||||
Validates that an email is valid
|
||||
:param email: email to validate
|
||||
:return: Raises Module.Validation exception if is invalid, else return the value "fixed"
|
||||
"""
|
||||
if len(email) > 254:
|
||||
raise Module.ValidationException(
|
||||
_('Email address is too long')
|
||||
)
|
||||
|
||||
if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
|
||||
raise Module.ValidationException(
|
||||
_('Email address is not valid')
|
||||
)
|
||||
|
||||
return email
|
||||
|
@@ -47,7 +47,7 @@ class HangedCleaner(Job):
|
||||
frecuency_cfg = GlobalConfig.MAX_INITIALIZING_TIME
|
||||
friendly_name = 'Hanged services checker'
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
now = getSqlDatetime()
|
||||
since_state = now - timedelta(
|
||||
seconds=GlobalConfig.MAX_INITIALIZING_TIME.getInt()
|
||||
@@ -56,7 +56,7 @@ class HangedCleaner(Job):
|
||||
# Filter for locating machine not ready
|
||||
flt = Q(state_date__lt=since_state, state=State.PREPARING) | Q(
|
||||
state_date__lt=since_state, state=State.USABLE, os_state=State.PREPARING
|
||||
)
|
||||
) | Q(state_date__lt=since_removing, state__in=[State.REMOVING, State.CANCELING])
|
||||
|
||||
withHangedServices = (
|
||||
ServicePool.objects.annotate(
|
||||
@@ -74,7 +74,7 @@ class HangedCleaner(Job):
|
||||
)
|
||||
| Q(
|
||||
userServices__state_date__lt=since_removing,
|
||||
userServices__state=State.REMOVING,
|
||||
userServices__state__in=[State.REMOVING, State.CANCELING],
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -96,7 +96,7 @@ class HangedCleaner(Job):
|
||||
continue
|
||||
logger.debug('Found hanged service %s', us)
|
||||
if (
|
||||
us.state == State.REMOVING
|
||||
us.state in [State.REMOVING, State.CANCELING]
|
||||
): # Removing too long, remark it as removable
|
||||
log.doLog(
|
||||
us,
|
||||
|
@@ -146,7 +146,7 @@ class ServiceCacheUpdater(Job):
|
||||
)
|
||||
inAssigned: int = (
|
||||
servicePool.assignedUserServices()
|
||||
.filter(userServiceManager().getStateFilter(servicePool))
|
||||
.filter(userServiceManager().getStateFilter(servicePool.service))
|
||||
.count()
|
||||
)
|
||||
# if we bypasses max cache, we will reduce it in first place. This is so because this will free resources on service provider
|
||||
|
@@ -32,12 +32,14 @@
|
||||
"""
|
||||
from importlib import import_module
|
||||
import logging
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
from django.conf import settings
|
||||
from uds.core.util.cache import Cache
|
||||
from uds.core.jobs import Job
|
||||
from uds.models import TicketStore
|
||||
from uds.models import TicketStore, Log, getSqlDatetime
|
||||
from uds.core.util import config, log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -65,7 +67,6 @@ class TicketStoreCleaner(Job):
|
||||
|
||||
|
||||
class SessionsCleaner(Job):
|
||||
|
||||
frecuency = 3600 * 24 * 7 # Once a week will be enough
|
||||
friendly_name = 'User Sessions cleaner'
|
||||
|
||||
@@ -83,3 +84,20 @@ class SessionsCleaner(Job):
|
||||
pass # No problem if no cleanup
|
||||
|
||||
logger.debug('Done session cleanup')
|
||||
|
||||
|
||||
class AuditLogCleanup(Job):
|
||||
frecuency = 60 * 60 * 24 # Once a day
|
||||
friendly_name = 'Audit Log Cleanup'
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Cleans logs older than days
|
||||
"""
|
||||
Log.objects.filter(
|
||||
date__lt=getSqlDatetime()
|
||||
- datetime.timedelta(
|
||||
days=config.GlobalConfig.MAX_AUDIT_LOGS_DURATION.getInt()
|
||||
),
|
||||
owner_type=log.OWNER_TYPE_AUDIT,
|
||||
).delete()
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@
|
||||
"""
|
||||
@author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import typing
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
@@ -42,10 +43,10 @@ class Command(BaseCommand):
|
||||
args = "<mod.name=value mod.name=value mod.name=value...>"
|
||||
help = "Updates configuration values. If mod is omitted, UDS will be used. Omit whitespaces betwen name, =, and value (they must be a single param)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument('name_value', nargs='+', type=str)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
def handle(self, *args, **options) -> None:
|
||||
logger.debug("Handling settings")
|
||||
GlobalConfig.initialize()
|
||||
try:
|
||||
@@ -62,5 +63,5 @@ class Command(BaseCommand):
|
||||
): # If not exists, try to store value without any special parameters
|
||||
Config.section(mod).value(name, value).get()
|
||||
except Exception as e:
|
||||
print('The command could not be processed: {}'.format(e))
|
||||
self.stderr.write('The command could not be processed: {}'.format(e))
|
||||
logger.exception('Exception processing %s', args)
|
||||
|
568
server/src/uds/management/commands/export.py
Normal file
568
server/src/uds/management/commands/export.py
Normal file
@@ -0,0 +1,568 @@
|
||||
# -*- 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
|
||||
"""
|
||||
from functools import reduce
|
||||
import logging
|
||||
import operator
|
||||
import typing
|
||||
import yaml
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Q
|
||||
|
||||
from uds import models
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import argparse
|
||||
from django.db.models import Model
|
||||
from uds.models.uuid_model import UUIDModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ModelType = typing.TypeVar('ModelType', bound='UUIDModel')
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
|
||||
def uuid_object_exporter(obj: 'UUIDModel') -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a uuid model to a dict
|
||||
"""
|
||||
return {
|
||||
'uuid': obj.uuid,
|
||||
}
|
||||
|
||||
|
||||
def managed_object_exporter(
|
||||
obj: models.ManagedObjectModel,
|
||||
) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a managed object to a dict
|
||||
"""
|
||||
# Get uuid model
|
||||
m = uuid_object_exporter(obj)
|
||||
# Extend with managed object fields
|
||||
m.update(
|
||||
{
|
||||
'name': obj.name,
|
||||
'comments': obj.comments,
|
||||
'data': obj.data,
|
||||
'data_type': obj.data_type,
|
||||
}
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
def provider_exporter(provider: models.Provider) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a provider to a dict
|
||||
"""
|
||||
p = managed_object_exporter(provider)
|
||||
p['maintenance_mode'] = provider.maintenance_mode
|
||||
return p
|
||||
|
||||
|
||||
def service_exporter(service: models.Service) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a service to a dict
|
||||
"""
|
||||
s = managed_object_exporter(service)
|
||||
s['provider'] = service.provider.uuid
|
||||
s['token'] = service.token
|
||||
return s
|
||||
|
||||
|
||||
def authenticator_exporter(
|
||||
authenticator: models.Authenticator,
|
||||
) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports an authenticator to a dict
|
||||
"""
|
||||
a = managed_object_exporter(authenticator)
|
||||
a['priority'] = authenticator.priority
|
||||
a['provider'] = authenticator.small_name
|
||||
a['visible'] = authenticator.visible
|
||||
return a
|
||||
|
||||
|
||||
def user_exporter(user: models.User) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a user to a dict
|
||||
"""
|
||||
u = uuid_object_exporter(user)
|
||||
u.update(
|
||||
{
|
||||
'manager': user.manager.uuid,
|
||||
'name': user.name,
|
||||
'comments': user.comments,
|
||||
'real_name': user.real_name,
|
||||
'state': user.state,
|
||||
'password': user.password,
|
||||
'mfa_data': user.mfa_data,
|
||||
'staff_member': user.staff_member,
|
||||
'is_admin': user.is_admin,
|
||||
'last_access': user.last_access,
|
||||
'parent': user.parent,
|
||||
'created': user.created,
|
||||
'groups': [g.uuid for g in user.groups.all()],
|
||||
}
|
||||
)
|
||||
return u
|
||||
|
||||
|
||||
def group_export(group: models.Group) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a group to a dict
|
||||
"""
|
||||
g = uuid_object_exporter(group)
|
||||
g.update(
|
||||
{
|
||||
'manager': group.manager.uuid,
|
||||
'name': group.name,
|
||||
'comments': group.comments,
|
||||
'state': group.state,
|
||||
'is_meta': group.is_meta,
|
||||
'meta_if_any': group.meta_if_any,
|
||||
'created': group.created,
|
||||
}
|
||||
)
|
||||
return g
|
||||
|
||||
|
||||
def transport_exporter(transport: models.Transport) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a transport to a dict
|
||||
"""
|
||||
t = managed_object_exporter(transport)
|
||||
t.update(
|
||||
{
|
||||
'priority': transport.priority,
|
||||
'nets_positive': transport.nets_positive,
|
||||
'allowed_oss': transport.allowed_oss,
|
||||
'label': transport.label,
|
||||
'networks': [n.uuid for n in transport.networks.all()],
|
||||
}
|
||||
)
|
||||
return t
|
||||
|
||||
|
||||
def network_exporter(network: models.Network) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a network to a dict
|
||||
"""
|
||||
n = uuid_object_exporter(network)
|
||||
n.update(
|
||||
{
|
||||
'name': network.name,
|
||||
'net_start': network.net_start,
|
||||
'net_end': network.net_end,
|
||||
'net_string': network.net_string,
|
||||
}
|
||||
)
|
||||
return n
|
||||
|
||||
|
||||
def osmanager_exporter(osmanager: models.OSManager) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports an osmanager to a dict
|
||||
"""
|
||||
o = managed_object_exporter(osmanager)
|
||||
return o
|
||||
|
||||
def calendar_exporter(calendar: models.Calendar) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a calendar to a dict
|
||||
"""
|
||||
c = uuid_object_exporter(calendar)
|
||||
c.update(
|
||||
{
|
||||
'name': calendar.name,
|
||||
'comments': calendar.comments,
|
||||
'modified': calendar.modified,
|
||||
}
|
||||
)
|
||||
return c
|
||||
|
||||
def calendar_rule_exporter(calendar_rule: models.CalendarRule) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Exports a calendar rule to a dict
|
||||
"""
|
||||
c = uuid_object_exporter(calendar_rule)
|
||||
c.update(
|
||||
{
|
||||
'calendar': calendar_rule.calendar.uuid,
|
||||
'name': calendar_rule.name,
|
||||
'comments': calendar_rule.comments,
|
||||
'start': calendar_rule.start,
|
||||
'end': calendar_rule.end,
|
||||
'frequency': calendar_rule.frequency,
|
||||
'interval': calendar_rule.interval,
|
||||
'duration': calendar_rule.duration,
|
||||
'duration_unit': calendar_rule.duration_unit,
|
||||
}
|
||||
)
|
||||
return c
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Export entities from UDS to be imported in another UDS instance'
|
||||
|
||||
VALID_ENTITIES: typing.Mapping[str, typing.Callable[[], str]]
|
||||
verbose: bool = True
|
||||
filter_args: typing.List[typing.Tuple[str, str]] = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.VALID_ENTITIES = {
|
||||
'providers': self.export_providers,
|
||||
'services': self.export_services,
|
||||
'authenticators': self.export_authenticators,
|
||||
'users': self.export_users,
|
||||
'groups': self.export_groups,
|
||||
'networks': self.export_networks,
|
||||
'transports': self.export_transports,
|
||||
'osmanagers': self.export_osmanagers,
|
||||
}
|
||||
|
||||
def add_arguments(self, parser: 'argparse.ArgumentParser') -> None:
|
||||
# Accepts a list of valid entities to export
|
||||
parser.add_argument(
|
||||
'entities',
|
||||
nargs='+',
|
||||
choices=self.VALID_ENTITIES.keys(),
|
||||
default=self.VALID_ENTITIES.keys(),
|
||||
help='Entities to export',
|
||||
)
|
||||
|
||||
# Output file name (will be appended .csv or .yaml)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
action='store',
|
||||
dest='output',
|
||||
default='/tmp/export.yaml',
|
||||
help='Output file name. Defaults to /tmp/export.yaml',
|
||||
)
|
||||
|
||||
# Filter ALL entities by name, multiple names can be specified
|
||||
parser.add_argument(
|
||||
'--filter-name',
|
||||
action='append',
|
||||
dest='filter_name',
|
||||
default=[],
|
||||
help='Filter ALL entities by name',
|
||||
)
|
||||
|
||||
# Filter ALL entities by uuid, multiple uuids can be specified
|
||||
parser.add_argument(
|
||||
'--filter-uuid',
|
||||
action='append',
|
||||
dest='filter_uuid',
|
||||
default=[],
|
||||
help='Filter ALL entities by uuid',
|
||||
)
|
||||
|
||||
# quiet mode
|
||||
parser.add_argument(
|
||||
'--quiet',
|
||||
action='store_false',
|
||||
dest='verbose',
|
||||
default=True,
|
||||
help='Quiet mode',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
self.verbose = options['verbose']
|
||||
|
||||
if self.verbose:
|
||||
self.stderr.write(f'Exporting entities: {",".join(options["entities"])}')
|
||||
|
||||
# Compose filter name for kwargs
|
||||
for i in options['filter_name']:
|
||||
self.filter_args.append(('name__icontains', i))
|
||||
|
||||
for i in options['filter_uuid']:
|
||||
self.filter_args.append(('uuid', i))
|
||||
|
||||
# some entities are redundant, so remove them from the list
|
||||
entities = self.remove_reduntant_entities(options['entities'])
|
||||
|
||||
# For each entity, export it as yaml to output file
|
||||
with open(options['output'], 'w') as f:
|
||||
for entity in entities:
|
||||
self.stderr.write(f'Exporting {entity}')
|
||||
f.write(self.VALID_ENTITIES[entity]())
|
||||
f.write('')
|
||||
|
||||
if self.verbose:
|
||||
self.stderr.write(f'Exported to {options["output"]}')
|
||||
|
||||
def apply_filter(
|
||||
self, model: typing.Type[ModelType]
|
||||
) -> typing.Iterable[ModelType]:
|
||||
"""
|
||||
Applies a filter to a model
|
||||
"""
|
||||
if self.verbose:
|
||||
# Filter is a filter name, and an array of values
|
||||
values = [f'{k.split("__")[0]}={v}' for k, v in self.filter_args]
|
||||
self.stderr.write(f'Filtering {model.__name__}: \n ', ending='')
|
||||
self.stderr.write("\n ".join(values))
|
||||
# Generate "OR" filter with all kwargs
|
||||
if self.filter_args:
|
||||
return model.objects.filter(reduce(operator.or_, (Q(**{k: v}) for k, v in self.filter_args)))
|
||||
return model.objects.all()
|
||||
|
||||
def output_count(
|
||||
self, message: str, iterable: typing.Iterable[T]
|
||||
) -> typing.Iterable[T]:
|
||||
"""
|
||||
Outputs the count of an iterable
|
||||
"""
|
||||
count = 0
|
||||
for v in iterable:
|
||||
count += 1
|
||||
if self.verbose:
|
||||
self.stderr.write(f'{message} {count}', ending='\r')
|
||||
yield v
|
||||
|
||||
if self.verbose:
|
||||
self.stderr.write('\n') # New line after count
|
||||
|
||||
def export_providers(self) -> str:
|
||||
"""
|
||||
Exports all providers to a list of dicts
|
||||
"""
|
||||
return '# Providers\n' + yaml.safe_dump(
|
||||
[provider_exporter(p) for p in self.apply_filter(models.Provider)]
|
||||
)
|
||||
|
||||
def export_services(self) -> str:
|
||||
# First, locate providers for services with the filter
|
||||
services_list = list(
|
||||
self.output_count(
|
||||
'Filtering services', self.apply_filter(models.Service)
|
||||
)
|
||||
)
|
||||
providers_list = set(
|
||||
[
|
||||
s.provider
|
||||
for s in self.output_count('Filtering providers', services_list)
|
||||
]
|
||||
)
|
||||
# Now, export those providers
|
||||
providers = [
|
||||
provider_exporter(p)
|
||||
for p in self.output_count('Saving providers', providers_list)
|
||||
]
|
||||
|
||||
# Then, export services with the filter
|
||||
services = [
|
||||
service_exporter(s)
|
||||
for s in self.output_count('Saving services', services_list)
|
||||
]
|
||||
|
||||
return (
|
||||
'# Providers\n'
|
||||
+ yaml.safe_dump(providers)
|
||||
+ '# Services\n'
|
||||
+ yaml.safe_dump(services)
|
||||
)
|
||||
|
||||
def export_authenticators(self) -> str:
|
||||
"""
|
||||
Exports all authenticators to a list of dicts
|
||||
"""
|
||||
return '# Authenticators\n' + yaml.safe_dump(
|
||||
[
|
||||
authenticator_exporter(a)
|
||||
for a in self.output_count(
|
||||
'Saving authenticators',
|
||||
self.apply_filter(models.Authenticator),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def export_users(self) -> str:
|
||||
"""
|
||||
Exports all users to a list of dicts
|
||||
"""
|
||||
# first, locate authenticators for users with the filter
|
||||
users_list = list(
|
||||
self.output_count(
|
||||
'Filtering users', self.apply_filter(models.User)
|
||||
)
|
||||
)
|
||||
authenticators_list = set(
|
||||
[
|
||||
u.manager
|
||||
for u in self.output_count('Filtering authenticators', users_list)
|
||||
]
|
||||
)
|
||||
# Now, groups that contains those users
|
||||
groups_list = set()
|
||||
for u in self.output_count('Filtering groups', users_list):
|
||||
groups_list.update(u.groups.all())
|
||||
|
||||
# now, export those authenticators
|
||||
authenticators = [
|
||||
authenticator_exporter(a)
|
||||
for a in self.output_count('Saving authenticators', authenticators_list)
|
||||
]
|
||||
|
||||
# then, export those groups
|
||||
groups = [
|
||||
group_export(g) for g in self.output_count('Saving groups', groups_list)
|
||||
]
|
||||
|
||||
# finally, export users with the filter
|
||||
users = [
|
||||
user_exporter(u) for u in self.output_count('Saving users', users_list)
|
||||
]
|
||||
return (
|
||||
'# Authenticators\n'
|
||||
+ yaml.safe_dump(authenticators)
|
||||
+ '# Groups\n'
|
||||
+ yaml.safe_dump(groups)
|
||||
+ '# Users\n'
|
||||
+ yaml.safe_dump(users)
|
||||
)
|
||||
|
||||
def export_groups(self) -> str:
|
||||
"""
|
||||
Exports all groups to a list of dicts
|
||||
"""
|
||||
# First export authenticators for groups with the filter
|
||||
groups_list = list(
|
||||
self.output_count(
|
||||
'Filtering groups', self.apply_filter(models.Group)
|
||||
)
|
||||
)
|
||||
authenticators_list = set(
|
||||
[
|
||||
g.manager
|
||||
for g in self.output_count('Filtering authenticators', groups_list)
|
||||
]
|
||||
)
|
||||
authenticators = [
|
||||
authenticator_exporter(a)
|
||||
for a in self.output_count('Saving authenticators', authenticators_list)
|
||||
]
|
||||
|
||||
# then, export groups with the filter
|
||||
groups = [
|
||||
group_export(g) for g in self.output_count('Saving groups', groups_list)
|
||||
]
|
||||
|
||||
return (
|
||||
'# Authenticators\n'
|
||||
+ yaml.safe_dump(authenticators)
|
||||
+ '# Groups\n'
|
||||
+ yaml.safe_dump(groups)
|
||||
)
|
||||
|
||||
def export_networks(self) -> str:
|
||||
"""
|
||||
Exports all networks to a list of dicts
|
||||
"""
|
||||
return '# Networks\n' + yaml.safe_dump(
|
||||
[
|
||||
network_exporter(n)
|
||||
for n in self.output_count(
|
||||
'Saving networks', self.apply_filter(models.Network)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def export_transports(self) -> str:
|
||||
"""
|
||||
Exports all transports to a list of dicts
|
||||
"""
|
||||
# First, export networks for transports with the filter
|
||||
transports_list = list(
|
||||
self.output_count(
|
||||
'Filtering transports', self.apply_filter(models.Transport)
|
||||
)
|
||||
)
|
||||
networks_list = set()
|
||||
for t in self.output_count('Filtering networks', transports_list):
|
||||
networks_list.update(t.networks.all())
|
||||
networks = [
|
||||
network_exporter(n)
|
||||
for n in self.output_count('Saving networks', networks_list)
|
||||
]
|
||||
|
||||
# then, export transports with the filter
|
||||
transports = [
|
||||
transport_exporter(t)
|
||||
for t in self.output_count('Saving transports', transports_list)
|
||||
]
|
||||
|
||||
return (
|
||||
'# Networks\n'
|
||||
+ yaml.safe_dump(networks)
|
||||
+ '# Transports\n'
|
||||
+ yaml.safe_dump(transports)
|
||||
)
|
||||
|
||||
def export_osmanagers(self) -> str:
|
||||
"""
|
||||
Exports all osmanagers to a list of dicts
|
||||
"""
|
||||
return '# OSManagers\n' + yaml.safe_dump(
|
||||
[
|
||||
osmanager_exporter(o)
|
||||
for o in self.output_count(
|
||||
'Saving osmanagers', self.apply_filter(models.OSManager)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def remove_reduntant_entities(self, entities: typing.List[str]) -> typing.List[str]:
|
||||
"""
|
||||
Removes redundant entities from the list
|
||||
"""
|
||||
REPLACES: typing.Mapping[str, typing.List[str]] = {
|
||||
'users': ['authenticators', 'groups'],
|
||||
'groups': ['authenticators'],
|
||||
'authenticators': [],
|
||||
'transports': ['networks'],
|
||||
'networks': [],
|
||||
'osmanagers': [],
|
||||
'services': ['providers'],
|
||||
'providers': [],
|
||||
}
|
||||
entities = list(set(entities)) # remove duplicates
|
||||
# Remove entities that are replaced by other entities
|
||||
for entity in entities:
|
||||
for replace in REPLACES.get(entity, []):
|
||||
if replace in entities:
|
||||
entities.remove(replace)
|
||||
|
||||
return entities
|
91
server/src/uds/management/commands/showconfig.py
Normal file
91
server/src/uds/management/commands/showconfig.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# -*- 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. 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 csv
|
||||
import yaml
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from uds.core.util import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Show current PUBLIC configuration of UDS broker (passwords are not shown)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--csv',
|
||||
action='store_true',
|
||||
dest='csv',
|
||||
default=False,
|
||||
help='Shows configuration in CVS format',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--yaml',
|
||||
action='store_true',
|
||||
dest='yaml',
|
||||
default=False,
|
||||
help='Shows configuration in YAML format',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
logger.debug("Show settings")
|
||||
config.GlobalConfig.initialize()
|
||||
try:
|
||||
writer: typing.Any = None
|
||||
if options['csv']:
|
||||
# Print header
|
||||
writer = csv.writer(self.stdout, delimiter=';', quotechar='"', quoting=csv.QUOTE_MINIMAL)
|
||||
writer.writerow(['Section', 'Name', 'Value'])
|
||||
elif options['yaml']:
|
||||
writer = {} # Create a dict to store data, and write at the end
|
||||
# Get sections, key, value as a list of tuples
|
||||
for section, data in config.Config.getConfigValues().items():
|
||||
for key, value in data.items():
|
||||
# value is a dict, get 'value' key
|
||||
if options['csv']:
|
||||
writer.writerow([section, key, value['value']])
|
||||
elif options['yaml']:
|
||||
if section not in writer:
|
||||
writer[section] = {}
|
||||
writer[section][key] = value['value']
|
||||
else:
|
||||
v = value['value'].replace('\n', '\\n')
|
||||
self.stdout.write(f'{section}.{key}="{v}"')
|
||||
if options['yaml']:
|
||||
self.stdout.write(yaml.safe_dump(writer, default_flow_style=False))
|
||||
except Exception as e:
|
||||
print('The command could not be processed: {}'.format(e))
|
||||
logger.exception('Exception processing %s', args)
|
@@ -161,8 +161,8 @@ class Command(BaseCommand):
|
||||
|
||||
if not start and not stop:
|
||||
if pid:
|
||||
sys.stdout.write(
|
||||
self.stdout.write(
|
||||
"Task manager found running (pid file exists: {0})\n".format(pid)
|
||||
)
|
||||
else:
|
||||
sys.stdout.write("Task manager not foud (pid file do not exits)\n")
|
||||
self.stdout.write("Task manager not foud (pid file do not exits)\n")
|
||||
|
379
server/src/uds/management/commands/tree.py
Normal file
379
server/src/uds/management/commands/tree.py
Normal file
@@ -0,0 +1,379 @@
|
||||
# -*- 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. 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 yaml
|
||||
import collections
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from uds.core.util import log
|
||||
from uds import models
|
||||
from uds.core.util.state import State
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core.module import Module
|
||||
from django.db import models as dbmodels
|
||||
|
||||
|
||||
def getSerializedFromManagedObject(
|
||||
mod: 'models.ManagedObjectModel',
|
||||
removableFields: typing.Optional[typing.List[str]] = None,
|
||||
) -> typing.Mapping[str, typing.Any]:
|
||||
try:
|
||||
obj = mod.getInstance()
|
||||
gui = {i['name']: i['gui']['type'] for i in obj.guiDescription()}
|
||||
values = obj.valuesDict()
|
||||
# Remove password fields
|
||||
for k, v in gui.items():
|
||||
if v == 'password':
|
||||
values[k] = '********'
|
||||
# Some names are know "secret data"
|
||||
for i in ('serverCertificate', 'privateKey'):
|
||||
if i in values:
|
||||
values[i] = '********'
|
||||
# remove removable fields
|
||||
for i in removableFields or []:
|
||||
if i in values:
|
||||
del values[i]
|
||||
# Append typeName to list
|
||||
values['typeName'] = str(obj.typeName)
|
||||
values['comments'] = mod.comments
|
||||
|
||||
return values
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def getSerializedFromModel(
|
||||
mod: 'dbmodels.Model',
|
||||
removableFields: typing.Optional[typing.List[str]] = None,
|
||||
passwordFields: typing.Optional[typing.List[str]] = None,
|
||||
) -> typing.Mapping[str, typing.Any]:
|
||||
removableFields = removableFields or []
|
||||
passwordFields = passwordFields or []
|
||||
try:
|
||||
values = mod._meta.managers[0].filter(pk=mod.pk).values()[0]
|
||||
for i in ['uuid', 'id'] + removableFields:
|
||||
if i in values:
|
||||
del values[i]
|
||||
|
||||
for i in passwordFields:
|
||||
if i in values:
|
||||
values[i] = '********'
|
||||
return values
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Outputs all UDS Trees of elements in YAML format"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--all-userservices',
|
||||
action='store_true',
|
||||
dest='alluserservices',
|
||||
default=False,
|
||||
help='Shows ALL user services, not just the ones with errors',
|
||||
)
|
||||
# Maximum items allowed for groups and user services
|
||||
parser.add_argument(
|
||||
'--max-items',
|
||||
action='store',
|
||||
dest='maxitems',
|
||||
default=100,
|
||||
help='Maximum elements exported for groups and user services',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
logger.debug("Show Tree")
|
||||
# firt, genertate Provider-service-servicepool tree
|
||||
cntr = 0
|
||||
|
||||
def counter(s: str) -> str:
|
||||
nonlocal cntr
|
||||
cntr += 1
|
||||
return f'{cntr:02d}.-{s}'
|
||||
|
||||
max_items = int(options['maxitems'])
|
||||
|
||||
tree = {}
|
||||
try:
|
||||
providers = {}
|
||||
for provider in models.Provider.objects.all():
|
||||
|
||||
services = {}
|
||||
totalServices = 0
|
||||
totalServicePools = 0
|
||||
totalUserServices = 0
|
||||
for service in provider.services.all():
|
||||
servicePools = {}
|
||||
numberOfServicePools = 0
|
||||
numberOfUserServices = 0
|
||||
for servicePool in service.deployedServices.all():
|
||||
# get assigned user services with ERROR status
|
||||
userServices = {}
|
||||
fltr = servicePool.userServices.all()
|
||||
if not options['alluserservices']:
|
||||
fltr = fltr.filter(state=State.ERROR)
|
||||
for item in fltr[:max_items]: # at most max_items items
|
||||
logs = [
|
||||
'{}: {} [{}] - {}'.format(
|
||||
l['date'],
|
||||
log.logStrFromLevel(l['level']),
|
||||
l['source'],
|
||||
l['message'],
|
||||
)
|
||||
for l in log.getLogs(item)
|
||||
]
|
||||
userServices[item.friendly_name] = {
|
||||
'_': {
|
||||
'id': item.uuid,
|
||||
'unique_id': item.unique_id,
|
||||
'friendly_name': item.friendly_name,
|
||||
'state': State.toString(item.state),
|
||||
'os_state': State.toString(item.os_state),
|
||||
'state_date': item.state_date,
|
||||
'creation_date': item.creation_date,
|
||||
'revision': item.publication
|
||||
and item.publication.revision
|
||||
or '',
|
||||
'is_cache': item.cache_level != 0,
|
||||
'ip': item.getProperty('ip', 'unknown'),
|
||||
'actor_version': item.getProperty(
|
||||
'actor_version', 'unknown'
|
||||
),
|
||||
},
|
||||
'logs': logs,
|
||||
}
|
||||
|
||||
numberOfUserServices = len(userServices)
|
||||
totalUserServices += numberOfUserServices
|
||||
|
||||
# get publications
|
||||
publications = {}
|
||||
for publication in servicePool.publications.all():
|
||||
# Get all changelogs for this publication
|
||||
try:
|
||||
changelogs = models.ServicePoolPublicationChangelog.objects.filter(
|
||||
publication=publication
|
||||
).values(
|
||||
'stamp', 'revision', 'log'
|
||||
)
|
||||
changelogs = list(changelogs)
|
||||
except Exception:
|
||||
changelogs = []
|
||||
|
||||
publications[publication.revision] = getSerializedFromModel(
|
||||
publication, ['data']
|
||||
)
|
||||
publications[publication.revision][
|
||||
'changelogs'
|
||||
] = changelogs
|
||||
|
||||
# get assigned groups
|
||||
groups = []
|
||||
for group in servicePool.assignedGroups.all():
|
||||
groups.append(group.pretty_name)
|
||||
|
||||
# get calendar actions
|
||||
calendarActions = {}
|
||||
for calendarAction in models.CalendarAction.objects.filter(
|
||||
service_pool=servicePool
|
||||
):
|
||||
calendarActions[calendarAction.calendar.name] = {
|
||||
'action': calendarAction.action,
|
||||
'params': calendarAction.prettyParams,
|
||||
'at_start': calendarAction.at_start,
|
||||
'events_offset': calendarAction.events_offset,
|
||||
'last_execution': calendarAction.last_execution,
|
||||
'next_execution': calendarAction.next_execution,
|
||||
}
|
||||
|
||||
# get calendar access
|
||||
calendarAccess = {}
|
||||
for ca in models.CalendarAccess.objects.filter(
|
||||
service_pool=servicePool
|
||||
):
|
||||
calendarAccess[ca.calendar.name] = ca.access
|
||||
|
||||
servicePools[f'{servicePool.name} ({numberOfUserServices})'] = {
|
||||
'_': getSerializedFromModel(servicePool),
|
||||
'userServices': userServices,
|
||||
'calendarAccess': calendarAccess,
|
||||
'calendarActions': calendarActions,
|
||||
'groups': groups,
|
||||
'publications': publications,
|
||||
}
|
||||
|
||||
numberOfServicePools = len(servicePools)
|
||||
totalServicePools += numberOfServicePools
|
||||
|
||||
services[f'{service.name} ({numberOfServicePools}, {numberOfUserServices})'] = {
|
||||
'_': getSerializedFromManagedObject(service),
|
||||
'servicePools': servicePools,
|
||||
}
|
||||
|
||||
totalServices += len(services)
|
||||
providers[
|
||||
f'{provider.name} ({totalServices}, {totalServicePools}, {totalUserServices})'
|
||||
] = {
|
||||
'_': getSerializedFromManagedObject(provider),
|
||||
'services': services,
|
||||
}
|
||||
|
||||
tree[counter('PROVIDERS')] = providers
|
||||
|
||||
# authenticators
|
||||
authenticators = {}
|
||||
for authenticator in models.Authenticator.objects.all():
|
||||
# Groups
|
||||
groups = {}
|
||||
for group in authenticator.groups.all()[:max_items]: # at most max_items items
|
||||
groups[group.name] = getSerializedFromModel(group, ['manager_id', 'name'])
|
||||
authenticators[authenticator.name] = {
|
||||
'_': getSerializedFromManagedObject(authenticator),
|
||||
'groups': groups,
|
||||
}
|
||||
|
||||
tree[counter('AUTHENTICATORS')] = authenticators
|
||||
|
||||
# transports
|
||||
transports = {}
|
||||
for transport in models.Transport.objects.all():
|
||||
transports[transport.name] = getSerializedFromManagedObject(transport)
|
||||
|
||||
tree[counter('TRANSPORTS')] = transports
|
||||
|
||||
# Networks
|
||||
networks = {}
|
||||
for network in models.Network.objects.all():
|
||||
networks[network.name] = {
|
||||
'networks': network.net_string,
|
||||
'transports': [t.name for t in network.transports.all()],
|
||||
}
|
||||
|
||||
tree[counter('NETWORKS')] = networks
|
||||
|
||||
# os managers
|
||||
osManagers = {}
|
||||
for osManager in models.OSManager.objects.all():
|
||||
osManagers[osManager.name] = getSerializedFromManagedObject(osManager)
|
||||
|
||||
tree[counter('OSMANAGERS')] = osManagers
|
||||
|
||||
# calendars
|
||||
calendars = {}
|
||||
for calendar in models.Calendar.objects.all():
|
||||
# calendar rules
|
||||
rules = {}
|
||||
for rule in models.CalendarRule.objects.filter(calendar=calendar):
|
||||
rules[rule.name] = getSerializedFromModel(
|
||||
rule, ['calendar_id', 'name']
|
||||
)
|
||||
|
||||
calendars[calendar.name] = {
|
||||
'_': getSerializedFromModel(calendar),
|
||||
'rules': rules,
|
||||
}
|
||||
|
||||
tree[counter('CALENDARS')] = calendars
|
||||
|
||||
# Metapools
|
||||
metapools = {}
|
||||
for metapool in models.MetaPool.objects.all():
|
||||
metapools[metapool.name] = getSerializedFromModel(metapool)
|
||||
|
||||
tree[counter('METAPOOLS')] = metapools
|
||||
|
||||
# accounts
|
||||
accounts = {}
|
||||
for account in models.Account.objects.all():
|
||||
accounts[account.name] = {
|
||||
'_': getSerializedFromModel(account),
|
||||
'usages': list(
|
||||
account.usages.all().values(
|
||||
'user_name', 'pool_name', 'start', 'end'
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
tree[counter('ACCOUNTS')] = accounts
|
||||
|
||||
# Service pool groups
|
||||
servicePoolGroups = {}
|
||||
for servicePoolGroup in models.ServicePoolGroup.objects.all():
|
||||
servicePoolGroups[servicePoolGroup.name] = {
|
||||
'comments': servicePoolGroup.comments,
|
||||
'servicePools': [sp.name for sp in servicePoolGroup.servicesPools.all()], # type: ignore
|
||||
}
|
||||
|
||||
tree[counter('SERVICEPOOLGROUPS')] = servicePoolGroups
|
||||
|
||||
# Gallery
|
||||
gallery = {}
|
||||
for galleryItem in models.Image.objects.all():
|
||||
gallery[galleryItem.name] = {
|
||||
'size': f'{galleryItem.width}x{galleryItem.height}',
|
||||
'stamp': galleryItem.stamp,
|
||||
'length': len(galleryItem.data),
|
||||
}
|
||||
|
||||
tree[counter('GALLERY')] = gallery
|
||||
|
||||
# Actor tokens
|
||||
actorTokens = {}
|
||||
for actorToken in models.ActorToken.objects.all():
|
||||
actorTokens[actorToken.hostname] = getSerializedFromModel(
|
||||
actorToken, passwordFields=['token']
|
||||
)
|
||||
|
||||
tree[counter('ACTORTOKENS')] = actorTokens
|
||||
|
||||
# Tunnel tokens
|
||||
tunnelTokens = {}
|
||||
for tunnelToken in models.TunnelToken.objects.all():
|
||||
tunnelTokens[tunnelToken.hostname] = getSerializedFromModel(
|
||||
tunnelToken, passwordFields=['token']
|
||||
)
|
||||
|
||||
tree[counter('TUNNELTOKENS')] = tunnelTokens
|
||||
|
||||
self.stdout.write(yaml.safe_dump(tree, default_flow_style=False))
|
||||
|
||||
except Exception as e:
|
||||
print('The command could not be processed: {}'.format(e))
|
||||
logger.exception('Exception processing %s', args)
|
29
server/src/uds/mfas/Email/__init__.py
Normal file
29
server/src/uds/mfas/Email/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- 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. 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.
|
||||
|
||||
from . import mfa
|
BIN
server/src/uds/mfas/Email/mail.png
Normal file
BIN
server/src/uds/mfas/Email/mail.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
280
server/src/uds/mfas/Email/mfa.py
Normal file
280
server/src/uds/mfas/Email/mfa.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# -*- 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. 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 email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from re import T
|
||||
import smtplib
|
||||
import ssl
|
||||
import typing
|
||||
import logging
|
||||
|
||||
from django.utils.translation import gettext_noop as _, gettext
|
||||
|
||||
from uds import models
|
||||
from uds.core import mfas
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import validators, decorators
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core.module import Module
|
||||
from uds.core.util.request import ExtendedHttpRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailMFA(mfas.MFA):
|
||||
typeName = _('Email Multi Factor')
|
||||
typeType = 'emailMFA'
|
||||
typeDescription = _('Email Multi Factor Authenticator')
|
||||
iconFile = 'mail.png'
|
||||
|
||||
hostname = gui.TextField(
|
||||
length=128,
|
||||
label=_('SMTP Host'),
|
||||
order=1,
|
||||
tooltip=_(
|
||||
'SMTP Server hostname or IP address. If you are using a '
|
||||
'non-standard port, add it after a colon, for example: '
|
||||
'smtp.gmail.com:587'
|
||||
),
|
||||
required=True,
|
||||
tab=_('SMTP Server'),
|
||||
)
|
||||
|
||||
security = gui.ChoiceField(
|
||||
label=_('Security'),
|
||||
tooltip=_('Security protocol to use'),
|
||||
values={
|
||||
'tls': _('TLS'),
|
||||
'ssl': _('SSL'),
|
||||
'none': _('None'),
|
||||
},
|
||||
order=2,
|
||||
required=True,
|
||||
tab=_('SMTP Server'),
|
||||
)
|
||||
username = gui.TextField(
|
||||
length=128,
|
||||
label=_('Username'),
|
||||
order=9,
|
||||
tooltip=_('User with access to SMTP server'),
|
||||
required=False,
|
||||
defvalue='',
|
||||
tab=_('SMTP Server'),
|
||||
)
|
||||
password = gui.PasswordField(
|
||||
lenth=128,
|
||||
label=_('Password'),
|
||||
order=10,
|
||||
tooltip=_('Password of the user with access to SMTP server'),
|
||||
required=False,
|
||||
defvalue='',
|
||||
tab=_('SMTP Server'),
|
||||
)
|
||||
|
||||
emailSubject = gui.TextField(
|
||||
length=128,
|
||||
defvalue='Verification Code',
|
||||
label=_('Subject'),
|
||||
order=3,
|
||||
tooltip=_('Subject of the email'),
|
||||
required=True,
|
||||
tab=_('Config'),
|
||||
)
|
||||
|
||||
fromEmail = gui.TextField(
|
||||
length=128,
|
||||
label=_('From Email'),
|
||||
order=11,
|
||||
tooltip=_('Email address that will be used as sender'),
|
||||
required=True,
|
||||
tab=_('Config'),
|
||||
)
|
||||
|
||||
enableHTML = gui.CheckBoxField(
|
||||
label=_('Enable HTML'),
|
||||
order=13,
|
||||
tooltip=_('Enable HTML in emails'),
|
||||
defvalue=True,
|
||||
tab=_('Config'),
|
||||
)
|
||||
|
||||
|
||||
allowLoginWithoutMFA = gui.ChoiceField(
|
||||
label=_('Policy for users without MFA support'),
|
||||
order=31,
|
||||
defaultValue='0',
|
||||
tooltip=_('Action for SMS response error'),
|
||||
required=True,
|
||||
values={
|
||||
'0': _('Allow user login'),
|
||||
'1': _('Deny user login'),
|
||||
'2': _('Allow user to login if it IP is in the networks list'),
|
||||
'3': _('Deny user to login if it IP is in the networks list'),
|
||||
},
|
||||
tab=_('Config'),
|
||||
)
|
||||
|
||||
networks = gui.MultiChoiceField(
|
||||
label=_('SMS networks'),
|
||||
rdonly=False,
|
||||
rows=5,
|
||||
order=32,
|
||||
tooltip=_('Networks for SMS authentication'),
|
||||
required=True,
|
||||
tab=_('Config'),
|
||||
)
|
||||
|
||||
def initialize(self, values: 'Module.ValuesType' = None):
|
||||
"""
|
||||
We will use the "autosave" feature for form fields
|
||||
"""
|
||||
if not values:
|
||||
return
|
||||
|
||||
# check hostname for stmp server si valid and is in the right format
|
||||
# that is a hostname or ip address with optional port
|
||||
# if hostname is not valid, we will raise an exception
|
||||
hostname = self.hostname.cleanStr()
|
||||
if not hostname:
|
||||
raise EmailMFA.ValidationException(_('Invalid SMTP hostname'))
|
||||
|
||||
# Now check is valid format
|
||||
if ':' in hostname:
|
||||
host, port = validators.validateHostPortPair(hostname)
|
||||
self.hostname.value = '{}:{}'.format(host, port)
|
||||
else:
|
||||
host = self.hostname.cleanStr()
|
||||
self.hostname.value = validators.validateHostname(
|
||||
host, 128, asPattern=False
|
||||
)
|
||||
|
||||
# now check from email and to email
|
||||
self.fromEmail.value = validators.validateEmail(self.fromEmail.value)
|
||||
|
||||
def html(self, request: 'ExtendedHttpRequest') -> str:
|
||||
return gettext('Check your mail. You will receive an email with the verification code')
|
||||
|
||||
@classmethod
|
||||
def initClassGui(cls) -> None:
|
||||
# Populate the networks list
|
||||
cls.networks.setValues([
|
||||
gui.choiceItem(v.uuid, v.name)
|
||||
for v in models.Network.objects.all().order_by('name') if v.uuid
|
||||
])
|
||||
|
||||
|
||||
def checkAction(self, action: str, request: 'ExtendedHttpRequest') -> bool:
|
||||
def checkIp() -> bool:
|
||||
return any(i.ipInNetwork(request.ip) for i in models.Network.objects.filter(uuid__in = self.networks.value))
|
||||
|
||||
if action == '0':
|
||||
return True
|
||||
elif action == '1':
|
||||
return False
|
||||
elif action == '2':
|
||||
return checkIp()
|
||||
elif action == '3':
|
||||
return not checkIp()
|
||||
else:
|
||||
return False
|
||||
|
||||
def emptyIndentifierAllowedToLogin(self, request: 'ExtendedHttpRequest') -> bool:
|
||||
return self.checkAction(self.allowLoginWithoutMFA.value, request)
|
||||
|
||||
def label(self) -> str:
|
||||
return 'OTP received via email'
|
||||
|
||||
@decorators.threaded
|
||||
def doSendCode(self, request: 'ExtendedHttpRequest', identifier: str, code: str) -> None:
|
||||
# Send and email with the notification
|
||||
with self.login() as smtp:
|
||||
try:
|
||||
# Create message container
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = self.emailSubject.cleanStr()
|
||||
msg['From'] = self.fromEmail.cleanStr()
|
||||
msg['To'] = identifier
|
||||
|
||||
msg.attach(MIMEText(f'A login attemt has been made from {request.ip}.\nTo continue, provide the verification code {code}', 'plain'))
|
||||
|
||||
if self.enableHTML.value:
|
||||
msg.attach(MIMEText(f'<p>A login attemt has been made from <b>{request.ip}</b>.</p><p>To continue, provide the verification code <b>{code}</b></p>', 'html'))
|
||||
|
||||
smtp.sendmail(self.fromEmail.value, identifier, msg.as_string())
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error('Error sending email: {}'.format(e))
|
||||
raise
|
||||
|
||||
def sendCode(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, code: str) -> mfas.MFA.RESULT:
|
||||
self.doSendCode(request, identifier, code,)
|
||||
return mfas.MFA.RESULT.OK
|
||||
|
||||
def login(self) -> smtplib.SMTP:
|
||||
"""
|
||||
Login to SMTP server
|
||||
"""
|
||||
host = self.hostname.cleanStr()
|
||||
if ':' in host:
|
||||
host, ports = host.split(':')
|
||||
port = int(ports)
|
||||
else:
|
||||
port = None
|
||||
|
||||
if self.security.value in ('tls', 'ssl'):
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
if self.security.value == 'tls':
|
||||
if port:
|
||||
smtp = smtplib.SMTP(
|
||||
host,
|
||||
port,
|
||||
)
|
||||
else:
|
||||
smtp = smtplib.SMTP(host)
|
||||
smtp.starttls(context=context)
|
||||
else:
|
||||
if port:
|
||||
smtp = smtplib.SMTP_SSL(host, port, context=context)
|
||||
else:
|
||||
smtp = smtplib.SMTP_SSL(host, context=context)
|
||||
else:
|
||||
if port:
|
||||
smtp = smtplib.SMTP(host, port)
|
||||
else:
|
||||
smtp = smtplib.SMTP(host)
|
||||
|
||||
if self.username.value and self.password.value:
|
||||
smtp.login(self.username.value, self.password.value)
|
||||
|
||||
return smtp
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user