From ffe412dd26e026ac6b87505883510f2eded05e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=A9fix=20Estrada?= Date: Fri, 9 Jul 2021 16:21:16 +0200 Subject: [PATCH] New api v3 with JWT authentication and Frontend with JWT --- CHANGELOG.md | 54 +- CONTRIBUTING.md | 62 +- README.md | 119 +-- api/docker/Dockerfile | 9 +- api/docker/requirements.pip3 | 23 +- api/docker/run.sh | 6 + api/src/api/__init__.py | 53 +- api/src/api/auth/authentication.py | 15 +- api/src/api/auth/tokens.py | 113 +++ api/src/api/libv2/api_deployments.py | 65 +- api/src/api/libv2/api_desktops_common.py | 18 +- api/src/api/libv2/api_desktops_persistent.py | 65 +- api/src/api/libv2/api_downloads.py | 288 +++++++ api/src/api/libv2/api_socketio_deployments.py | 115 +++ api/src/api/libv2/api_socketio_domains.py | 195 +++++ api/src/api/libv2/api_socketio_secrets.py | 94 +++ api/src/api/libv2/api_sundry.py | 2 +- api/src/api/libv2/api_templates.py | 25 +- api/src/api/libv2/api_users.py | 289 +++++-- api/src/api/libv2/ds.py | 10 +- api/src/api/libv2/helpers.py | 79 ++ api/src/api/libv2/isardViewer.py | 216 +++-- api/src/api/libv2/load_config.py | 57 +- api/src/api/templates/jumper.html | 16 +- api/src/api/views/AdminUsersView.py | 473 +++++++++++ api/src/api/views/CommonView.py | 23 +- api/src/api/views/DeploymentsView.py | 22 +- .../api/views/DesktopsNonPersistentView.py | 29 +- api/src/api/views/DesktopsPersistentView.py | 176 ++-- api/src/api/views/DownloadsView.py | 45 ++ api/src/api/views/HypervisorsView.py | 51 ++ api/src/api/views/JumperViewerView.py | 10 +- api/src/api/views/PublicView.py | 90 +++ api/src/api/views/TemplatesView.py | 119 ++- api/src/api/views/UsersView.py | 439 +++------- api/src/api/views/XmlView.py | 13 +- api/src/api/views/__ApiViews.py | 71 +- api/src/api/views/decorators.py | 147 ++++ api/src/startv3.py | 18 + api/src/tests/01_users_test.py | 428 ++++++++++ api/srcv2/api/__init__.py | 55 ++ api/srcv2/api/auth/__init__.py | 0 api/srcv2/api/auth/authentication.py | 371 +++++++++ api/srcv2/api/auth/ldapauth.py | 143 ++++ api/srcv2/api/libv2/__apiv2.py | 749 ++++++++++++++++++ api/srcv2/api/libv2/__unused.py | 217 +++++ api/srcv2/api/libv2/api_deployments.py | 86 ++ api/srcv2/api/libv2/api_desktops_common.py | 130 +++ .../api/libv2/api_desktops_nonpersistent.py | 155 ++++ .../api/libv2/api_desktops_persistent.py | 177 +++++ api/srcv2/api/libv2/api_sundry.py | 42 + api/srcv2/api/libv2/api_templates.py | 99 +++ api/srcv2/api/libv2/api_users.py | 358 +++++++++ api/srcv2/api/libv2/api_xml.py | 40 + api/srcv2/api/libv2/apiv2_exc.py | 120 +++ api/srcv2/api/libv2/carbon.py | 54 ++ api/srcv2/api/libv2/ds.py | 130 +++ api/srcv2/api/libv2/flask_rethink.py | 47 ++ api/srcv2/api/libv2/helpers.py | 73 ++ api/srcv2/api/libv2/isardViewer.py | 397 ++++++++++ api/srcv2/api/libv2/isardVpn.py | 79 ++ api/srcv2/api/libv2/load_config.py | 61 ++ api/srcv2/api/libv2/log.py | 27 + api/srcv2/api/libv2/quotas.py | 83 ++ api/srcv2/api/libv2/quotas_exc.py | 57 ++ api/srcv2/api/libv2/telegram.py | 30 + api/srcv2/api/libv2/webapp_quotas.py | 392 +++++++++ api/srcv2/api/static/logo.svg | 44 + api/srcv2/api/templates/error.html | 66 ++ api/srcv2/api/templates/jumper.html | 106 +++ api/srcv2/api/templates/logo.svg | 44 + api/srcv2/api/views/CommonView.py | 214 +++++ api/srcv2/api/views/DeploymentsView.py | 50 ++ .../api/views/DesktopsNonPersistentView.py | 187 +++++ api/srcv2/api/views/DesktopsPersistentView.py | 216 +++++ api/srcv2/api/views/JumperViewerView.py | 62 ++ api/{src => srcv2}/api/views/SundryView.py | 0 api/srcv2/api/views/TemplatesView.py | 102 +++ api/srcv2/api/views/UsersView.py | 445 +++++++++++ api/srcv2/api/views/XmlView.py | 58 ++ api/srcv2/api/views/__ApiViews.py | 504 ++++++++++++ api/srcv2/api/views/__init__.py | 0 api/{src => srcv2}/api_client_test.py | 0 api/{src => srcv2}/start.py | 0 .../authentication/authentication.go | 298 +++++++ .../authentication/provider/google.go | 107 +++ .../authentication/provider/local.go | 89 +++ .../authentication/provider/oauth2.go | 44 + .../authentication/provider/provider.go | 51 ++ authentication/authentication/testing.go | 36 + authentication/build/package/Dockerfile | 36 + authentication/cfg/cfg.go | 55 ++ authentication/cmd/authentication/main.go | 51 ++ authentication/model/user.go | 62 ++ authentication/model/user_test.go | 102 +++ authentication/transport/http/helpers.go | 60 ++ authentication/transport/http/helpers_test.go | 83 ++ authentication/transport/http/http.go | 163 ++++ authentication/transport/http/http_test.go | 107 +++ backend/Makefile | 3 - backend/TODO.md | 12 - backend/api/api.go | 155 ---- backend/api/category.go | 21 - backend/api/check.go | 46 -- backend/api/config.go | 18 - backend/api/cookie.go | 76 -- backend/api/create.go | 33 - backend/api/deployments.go | 80 -- backend/api/desktops.go | 192 ----- backend/api/login.go | 27 - backend/api/logout.go | 65 -- backend/api/register.go | 42 - backend/api/templates.go | 27 - backend/api/user.go | 17 - backend/api/vpn.go | 19 - backend/auth/auth.go | 77 -- backend/auth/provider/github.go | 101 --- backend/auth/provider/google.go | 105 --- backend/auth/provider/local.go | 102 --- backend/auth/provider/oauth2.go | 146 ---- backend/auth/provider/provider.go | 186 ----- backend/auth/provider/saml.go | 180 ----- backend/build/package/Dockerfile | 18 - backend/cfg/cfg.go | 57 -- backend/cmd/backend/main.go | 155 ---- backend/env/env.go | 31 - backend/go.mod | 32 - backend/isard/category.go | 70 -- backend/isard/deployment.go | 49 -- backend/isard/desktop.go | 183 ----- backend/isard/isard.go | 89 --- backend/isard/register.go | 72 -- backend/isard/user.go | 283 ------- backend/isard/viewer.go | 51 -- backend/isard/vpn.go | 36 - backend/isardAdmin/isardAdmin.go | 29 - backend/model/desktop.go | 14 - backend/model/errors.go | 5 - backend/model/template.go | 9 - backend/model/user.go | 46 -- backend/pkg/utils/errors.go | 19 - build.sh | 4 +- docker-compose-parts/api.devel.yml | 4 +- docker-compose-parts/authentication.build.yml | 6 + docker-compose-parts/authentication.yml | 17 + docker-compose-parts/backend.build.yml | 6 - docker-compose-parts/backend.yml | 32 - docker-compose-parts/guac.yml | 4 +- docker/portal/haproxy.conf | 45 +- docker/static/noVNC/src | 2 +- engine/engine/initdb/populate.py | 13 +- frontend/package.json | 7 +- frontend/public/favicon.ico | Bin 5430 -> 24838 bytes frontend/src/App.vue | 12 + frontend/src/assets/logo.svg | 104 ++- frontend/src/assets/styles.css | 24 +- frontend/src/components/DeploymentBar.vue | 47 ++ frontend/src/components/DeploymentCard.vue | 52 ++ frontend/src/components/DeploymentsBar.vue | 19 + frontend/src/components/DeploymentsList.vue | 81 ++ frontend/src/components/Language.vue | 3 +- frontend/src/components/NewNavBar.vue | 9 +- frontend/src/components/NoVNC.vue | 53 ++ frontend/src/components/TableList.vue | 2 +- frontend/src/i18n.js | 10 +- frontend/src/locales/ca.json | 38 +- frontend/src/locales/de.json | 8 +- frontend/src/locales/en.json | 42 +- frontend/src/locales/es.json | 42 +- frontend/src/locales/eu.json | 4 +- frontend/src/locales/fr.json | 8 +- frontend/src/locales/ru.json | 4 +- frontend/src/main.js | 33 +- frontend/src/router/auth.js | 52 +- frontend/src/router/index.js | 18 + frontend/src/shared/constants.js | 4 + frontend/src/store/index.js | 95 ++- frontend/src/store/modules/auth.js | 76 +- frontend/src/store/modules/config.js | 9 +- frontend/src/store/modules/deployments.js | 123 +++ frontend/src/store/modules/desktops.js | 74 +- frontend/src/store/modules/sockets.js | 19 + frontend/src/store/modules/templates.js | 5 +- frontend/src/store/modules/vpn.js | 5 +- frontend/src/utils/authUtils.js | 12 + frontend/src/utils/axios.js | 53 ++ frontend/src/utils/deploymentsUtils.js | 41 + frontend/src/utils/desktopsUtils.js | 32 +- frontend/src/utils/socket-instance.js | 9 + frontend/src/utils/stringUtils.js | 5 + frontend/src/views/Deployment.vue | 71 ++ frontend/src/views/Deployments.vue | 48 ++ frontend/src/views/ErrorPage.vue | 3 +- frontend/src/views/Home.vue | 7 +- frontend/src/views/Login.vue | 176 ++-- frontend/src/views/Rdp.vue | 8 +- frontend/src/views/Register.vue | 57 +- frontend/yarn.lock | 110 ++- go.mod | 13 + backend/go.sum => go.sum | 306 ++++--- guac | 2 +- isardvdi.cfg.example | 19 + pkg/cfg/cfg.go | 99 +++ pkg/db/db.go | 23 + pkg/log/log.go | 40 + pkg/log/log_test.go | 59 ++ webapp/docker/requirements.pip3 | 3 +- webapp/webapp/webapp/auth/authentication.py | 23 +- webapp/webapp/webapp/lib/admin_api.py | 22 +- webapp/webapp/webapp/lib/api.py | 1 + webapp/webapp/webapp/lib/isardSocketio.py | 2 +- .../webapp/static/admin/js/external_apps.js | 89 +++ webapp/webapp/webapp/static/img/isard.svg | 104 ++- .../webapp/templates/admin/pages/users.html | 39 +- .../templates/pages/deployment_desktops.html | 4 +- webapp/webapp/webapp/templates/sidebar.html | 2 +- webapp/webapp/webapp/views/LoginViews.py | 6 +- 217 files changed, 12911 insertions(+), 4526 deletions(-) create mode 100755 api/docker/run.sh create mode 100644 api/src/api/auth/tokens.py create mode 100644 api/src/api/libv2/api_downloads.py create mode 100644 api/src/api/libv2/api_socketio_deployments.py create mode 100644 api/src/api/libv2/api_socketio_domains.py create mode 100644 api/src/api/libv2/api_socketio_secrets.py create mode 100644 api/src/api/views/AdminUsersView.py create mode 100644 api/src/api/views/DownloadsView.py create mode 100644 api/src/api/views/HypervisorsView.py create mode 100644 api/src/api/views/PublicView.py create mode 100644 api/src/api/views/decorators.py create mode 100644 api/src/startv3.py create mode 100644 api/src/tests/01_users_test.py create mode 100644 api/srcv2/api/__init__.py create mode 100644 api/srcv2/api/auth/__init__.py create mode 100644 api/srcv2/api/auth/authentication.py create mode 100644 api/srcv2/api/auth/ldapauth.py create mode 100644 api/srcv2/api/libv2/__apiv2.py create mode 100644 api/srcv2/api/libv2/__unused.py create mode 100644 api/srcv2/api/libv2/api_deployments.py create mode 100644 api/srcv2/api/libv2/api_desktops_common.py create mode 100644 api/srcv2/api/libv2/api_desktops_nonpersistent.py create mode 100644 api/srcv2/api/libv2/api_desktops_persistent.py create mode 100644 api/srcv2/api/libv2/api_sundry.py create mode 100644 api/srcv2/api/libv2/api_templates.py create mode 100644 api/srcv2/api/libv2/api_users.py create mode 100644 api/srcv2/api/libv2/api_xml.py create mode 100644 api/srcv2/api/libv2/apiv2_exc.py create mode 100644 api/srcv2/api/libv2/carbon.py create mode 100644 api/srcv2/api/libv2/ds.py create mode 100644 api/srcv2/api/libv2/flask_rethink.py create mode 100644 api/srcv2/api/libv2/helpers.py create mode 100644 api/srcv2/api/libv2/isardViewer.py create mode 100644 api/srcv2/api/libv2/isardVpn.py create mode 100644 api/srcv2/api/libv2/load_config.py create mode 100644 api/srcv2/api/libv2/log.py create mode 100644 api/srcv2/api/libv2/quotas.py create mode 100644 api/srcv2/api/libv2/quotas_exc.py create mode 100644 api/srcv2/api/libv2/telegram.py create mode 100644 api/srcv2/api/libv2/webapp_quotas.py create mode 100644 api/srcv2/api/static/logo.svg create mode 100644 api/srcv2/api/templates/error.html create mode 100644 api/srcv2/api/templates/jumper.html create mode 100644 api/srcv2/api/templates/logo.svg create mode 100644 api/srcv2/api/views/CommonView.py create mode 100644 api/srcv2/api/views/DeploymentsView.py create mode 100644 api/srcv2/api/views/DesktopsNonPersistentView.py create mode 100644 api/srcv2/api/views/DesktopsPersistentView.py create mode 100644 api/srcv2/api/views/JumperViewerView.py rename api/{src => srcv2}/api/views/SundryView.py (100%) create mode 100644 api/srcv2/api/views/TemplatesView.py create mode 100644 api/srcv2/api/views/UsersView.py create mode 100644 api/srcv2/api/views/XmlView.py create mode 100644 api/srcv2/api/views/__ApiViews.py create mode 100644 api/srcv2/api/views/__init__.py rename api/{src => srcv2}/api_client_test.py (100%) rename api/{src => srcv2}/start.py (100%) create mode 100644 authentication/authentication/authentication.go create mode 100644 authentication/authentication/provider/google.go create mode 100644 authentication/authentication/provider/local.go create mode 100644 authentication/authentication/provider/oauth2.go create mode 100644 authentication/authentication/provider/provider.go create mode 100644 authentication/authentication/testing.go create mode 100644 authentication/build/package/Dockerfile create mode 100644 authentication/cfg/cfg.go create mode 100644 authentication/cmd/authentication/main.go create mode 100644 authentication/model/user.go create mode 100644 authentication/model/user_test.go create mode 100644 authentication/transport/http/helpers.go create mode 100644 authentication/transport/http/helpers_test.go create mode 100644 authentication/transport/http/http.go create mode 100644 authentication/transport/http/http_test.go delete mode 100644 backend/Makefile delete mode 100644 backend/TODO.md delete mode 100644 backend/api/api.go delete mode 100644 backend/api/category.go delete mode 100644 backend/api/check.go delete mode 100644 backend/api/config.go delete mode 100644 backend/api/cookie.go delete mode 100644 backend/api/create.go delete mode 100644 backend/api/deployments.go delete mode 100644 backend/api/desktops.go delete mode 100644 backend/api/login.go delete mode 100644 backend/api/logout.go delete mode 100644 backend/api/register.go delete mode 100644 backend/api/templates.go delete mode 100644 backend/api/user.go delete mode 100644 backend/api/vpn.go delete mode 100644 backend/auth/auth.go delete mode 100644 backend/auth/provider/github.go delete mode 100644 backend/auth/provider/google.go delete mode 100644 backend/auth/provider/local.go delete mode 100644 backend/auth/provider/oauth2.go delete mode 100644 backend/auth/provider/provider.go delete mode 100644 backend/auth/provider/saml.go delete mode 100644 backend/build/package/Dockerfile delete mode 100644 backend/cfg/cfg.go delete mode 100644 backend/cmd/backend/main.go delete mode 100644 backend/env/env.go delete mode 100644 backend/go.mod delete mode 100644 backend/isard/category.go delete mode 100644 backend/isard/deployment.go delete mode 100644 backend/isard/desktop.go delete mode 100644 backend/isard/isard.go delete mode 100644 backend/isard/register.go delete mode 100644 backend/isard/user.go delete mode 100644 backend/isard/viewer.go delete mode 100644 backend/isard/vpn.go delete mode 100644 backend/isardAdmin/isardAdmin.go delete mode 100644 backend/model/desktop.go delete mode 100644 backend/model/errors.go delete mode 100644 backend/model/template.go delete mode 100644 backend/model/user.go delete mode 100644 backend/pkg/utils/errors.go create mode 100644 docker-compose-parts/authentication.build.yml create mode 100644 docker-compose-parts/authentication.yml delete mode 100644 docker-compose-parts/backend.build.yml delete mode 100644 docker-compose-parts/backend.yml create mode 100644 frontend/src/components/DeploymentBar.vue create mode 100644 frontend/src/components/DeploymentCard.vue create mode 100644 frontend/src/components/DeploymentsBar.vue create mode 100644 frontend/src/components/DeploymentsList.vue create mode 100644 frontend/src/components/NoVNC.vue create mode 100644 frontend/src/store/modules/deployments.js create mode 100644 frontend/src/store/modules/sockets.js create mode 100644 frontend/src/utils/authUtils.js create mode 100644 frontend/src/utils/axios.js create mode 100644 frontend/src/utils/deploymentsUtils.js create mode 100644 frontend/src/utils/socket-instance.js create mode 100644 frontend/src/utils/stringUtils.js create mode 100644 frontend/src/views/Deployment.vue create mode 100644 frontend/src/views/Deployments.vue create mode 100644 go.mod rename backend/go.sum => go.sum (69%) create mode 100644 pkg/cfg/cfg.go create mode 100644 pkg/db/db.go create mode 100644 pkg/log/log.go create mode 100644 pkg/log/log_test.go create mode 100644 webapp/webapp/webapp/static/admin/js/external_apps.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ecf953ed..3c5bc091c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,61 @@ All notable changes to this project will be documented in this file. -## [develop] - not released +## [3.0.0] - 2021-08-25 | Gran Paradiso -### Upgrade +*Note*: it is possible to upgrade from version `2.0.0-rc1` to `3.0.0`, but we don't assure you everything will work (though it probably will). For a stable installation, start from scratch. -#### After upgrade +### Update tricks + +#### Before upgrade +```sh +mkdir -p /opt/isard/hypervisor +docker cp isard-hypervisor:/etc/ssh /opt/isard/hypervisor/sshd_keys +rm /opt/isard/hypervisor/sshd_keys/moduli /opt/isard/hypervisor/sshd_keys/ssh_config /opt/isard/hypervisor/sshd_keys/sshd_config +``` +More info [here](https://github.com/isard-vdi/isard/pull/290) -If you bring up 2.0-rc1 version and then upgrade, as you will have the certs already created, they don't get the correct permissions. -Fix permissions running the following command: +```sh +docker network rm isard-network ``` +More info [here](https://gitlab.com/isard/isardvdi/-/merge_requests/276#note_626216072) + +#### After upgrade +```sh docker-compose run isard-hypervisor chown -R qemu /etc/pki/libvirt-spice ``` +More info [here](https://github.com/isard-vdi/isard/issues/278#issuecomment-716102809) + + +### Added + +- New frontend +- Set a custom logo +- Single Sign On +- Full Nvidia GPU support +- RDP viewer +- RDP browser viewer (using Guacamole) +- Deployments (desktop groups) +- Desktop soft shutdown +- Desktop sharing through an unique URL +- VPN connection for each user + +### Fixed + +- *Lots* of bugs fixed in all the services + +### Changed + +- Advanced interface styles cleanup +- Updated Libvirt & QEMU to newer releases +- Updated lots of frontend & webapp Javascript dependencies +- Development moved to Gitlab +- Renamed the 'Updates' section to 'Downloads', in the advanced interface + +### Removed + +- Old frontend + ## [2.0.0-rc1] - 2020-08-03 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38ccd3e86..118d090aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,29 +1,37 @@ # Contributing -## New feature - -1. Fork the `isard-vdi/isard` repository -2. Clone **your** Isard fork and move (if you already have your fork clonned, make sure you have the latest changes: `git fetch upstream`) -3. Add the upstream remote: `git remote add upstream https://github.com/isard-vdi/isard` - -1. Initialize Git Flow: `git flow init` -2. Create the feature: `git flow feature start ` -3. Work and commit it -4. Publish the feature branch: `git flow feature publish ` -5. Create a pull request from `your username/isard` `feature/` to `isard-vdi/isard` `develop` branch - - - -## New release - -1. Clone the `isard-vdi/isard` repository -2. Create the release: `git flow release start X.X.X` -3. Publish the release branch: `git flow publish release X.X.X` -4. Create a pull request from the `isard-vdi/isard` `release/X.X.X` to `isard-vdi/isard` `master` -5. Update the Changelog, the `docker-compose.yml` file... -6. Merge the release to master -7. Create a new release to GitHub using as description the Changelog for the version -8. Pull the changes to the local `isard-vdi/isard` clone -9. Change to the new version tag: `git checkout X.X.X` -10. Build the Docker images and push them to Docker Hub - +This file is going to be used to document de development process of IsardVDI, both for newcomers and old contributors! + +## Development model + +- IsardVDI is developed in a *rolling release* model. This means that every change done, is going to be a new version +- Uses [semver](https://semver.org/) + + If the changes are a bugfix, increase the PATCH (x.x.X) + + If the changes introduce a new feature, change the MINOR (x.X.x) + + If some changes break the upgrading process, change the MAJOR (X.x.x) +- Does not provide support for old versions (e.g. if we have version 3.1.1 and 3.2.0 is out, there's never going to be version 3.1.2) + +## Example + +Let's say we have found a bug and have a solution: + +1. For the `isard/isardvdi` repository +2. Clone **your** fork +3. Add the upstream remote: `git remote add upstream https://gitlab.com/isard/isardvdi` +4. If you already have the clone, make sure you have the latest changes: + +```sh +git checkout main +git pull upstream +``` + +5. Create a branch from there: `git checkout -b ` (please, pick a descriptive name!) +6. Work in this branch +7. Update the `CHANGELOG.md` and *commit* the changes. Write a [good and descriptive commit message](https://www.freecodecamp.org/news/writing-good-commit-messages-a-practical-guide/). +8. Make sure you're on the latest `upstream` commit: `git fetch upstream && git rebase upstream/main` +8. Push the branch to your remote: `git push` +9. Create a Merge Request to the `main` branch of the `isard/isardvdi` repository. Please be descriptive in both the title and the description! +10. Review the changes and decide it's ready for a release +11. Rebase again against the `upstream/main`. If there has been a release, use `git commit --amend` to edit the last commit and ensure the `CHANGELOG.md` is correct +12. Push to your fork and wait for someone to review the changes and merge it to `main` +13. Done! The GitLab CI will create the release, the tag and publish de Docker images! :) diff --git a/README.md b/README.md index c1414e84c..3a46175b7 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,104 @@ # Isard**VDI** -IsardVDI Logo +IsardVDI Logo -[![](https://img.shields.io/github/release/isard-vdi/isard.svg)](https://github.com/isard-vdi/isard/releases) [![](https://img.shields.io/badge/docker--compose-ready-blue.svg)](https://github.com/isard-vdi/isard/blob/master/docker-compose.yml) [![](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://isardvdi.readthedocs.io/en/latest/) [![](https://img.shields.io/badge/license-AGPL%20v3.0-brightgreen.svg)](https://github.com/isard-vdi/isard/blob/master/LICENSE) +[![release](https://img.shields.io/badge/dynamic/json.svg?label=release&url=https://gitlab.com/api/v4/projects/21522757/releases&query=0.name&color=blue)](https://gitlab.com/isard/isardvdi/-/releases) +[![docker-compose](https://img.shields.io/badge/docker--compose-ready-blue.svg)](https://isard.gitlab.io/isardvdi-docs/install/install/#quickstart) +[![docs](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://isard.gitlab.io/isardvdi-docs/) +[![license](https://img.shields.io/badge/license-AGPL%20v3.0-brightgreen.svg)](LICENSE) -Open Source KVM Virtual Desktops based on KVM Linux and dockers. +IsardVDI is a Free Software desktop virtualization platform. Some of its features are: -- Engine that monitors hypervisors and domains (desktops) +- **GPU support**: it supports the NVIDIA Grid platform +- **Easy to install**: using Docker and Docker Compose, you can deploy IsardVDI in minutes +- **Scalable**: you can manage multiple hypervisors and add / remove them depending on your needs +- **Fast**: start a desktop and connect to it in a matter of seconds +- **Versatile**: you can run all the OS supported by QEMU/KVM, and there are multiple viewers supported: + + *SPICE* + + *noVNC* (web) + + *RDP* + + *Guacamole RDP* (web) -- Websocket user interface with real time events. -- HTML5 and native SPICE client viewers - **IMPORTANT NOTE**: You cannot migrate from the version 1 to version 2, since there are many structural changes. You should backup your XML definition files and QCOW disks and import them in the new version. +## Table of contents -# Documentation +- [Quick Start](#quick-start) +- [Usage](#usage) +- [Documentation](#documentation) +- [Version upgrade notes](#version-upgrade-notes) +- [Contributing](#contributing) +- [Support and Contact](#support-and-contact) +- [Other links](#other-links) +- [License](#license) -Follow the extensive documentation to get the most of your installation: -- [https://isardvdi.readthedocs.io/en/develop/](https://isardvdi.readthedocs.io/en/develop/) -## Quick Start with docker & docker-compose +## Quick Start -### 1) *INSTALL docker & docker-compose* -- https://docs.docker.com/install/ -- https://docs.docker.com/compose/install/ +[https://isard.gitlab.io/isardvdi-docs/install/#quick-start](https://isard.gitlab.io/isardvdi-docs/install/#quick-start) -### 2) **Pull images and bring it up**: -``` -wget https://isardvdi.com/docker-compose.yml -docker-compose pull -docker-compose up -d -``` -Connect to **https://**/isard-admin with default user *admin* and password *IsardVDI* +## Usage -NOTE: +### Desktops -- All data will be created in your host /opt/isard folder -- Logs will be at /opt/isard-local +To download predefined and tested desktops, you can go to the `Downloads` section, in the `Administration` frontend. -## Custom build +If you want to create your own desktop: -There is an **isardvdi.cfg.example** file that you can copy to **isardvdi.cfg** and edit to fit your requirements. After that you can create your own *docker-compose.yml* file from that config by issuing *build.sh* script. +1. Go to `Media` section (in the `Administration` frontend), and download an ISO +2. After the download is finished, it will show a desktop icon where you can create the desktop. -Then bring it up with **docker-compose up -d** +### Templates -Please read the [documentation](https://isardvdi.readthedocs.io/en/develop/install/install/#main-parameters) to configure your IsardVDI installation +Create a template from a desktop (in the `Administration` frontend): -### Desktops +1. Open desktop details and click the `Template it` button. +2. Fill in the form and click on `create`. -You can directly go to *Updates* menu and download and test precreated desktops. +It will create a template from that desktop as it was now. You can create as many desktops identical to that template. -If you want to create your own desktop: -1. Go to Media menu and download an ISO -2. After the download is finished it will show a desktop icon where you can create the desktop. +![Main admin screen](https://isard.gitlab.io/isardvdi-docs/images/main.png) -You will find the created desktop in Desktops menu. Implemented encrypted viewers: -- HTML5 Viewer -- Native virt-viewer SPICE protocol. -### Templates +## Documentation -Create a template from a desktop: +Follow the extensive documentation to get the most of your installation: -1. Open desktop details and click on Template it button. -2. Fill in the form and click on create. +- [https://isard.gitlab.io/isardvdi-docs](https://isard.gitlab.io/isardvdi-docs) -It will create a template from that desktop as it was now. You can create as many desktops identical to that template. -### Updates -In Updates menu you will have access to different resources you can download from our IsardVDI updates server. +## Version upgrade notes: -![Main admin screen](https://isardvdi.readthedocs.io/en/latest/images/main.png) +- See [CHANGELOG.md](CHANGELOG.md) -## Documentation -- https://isardvdi.readthedocs.io/en/latest/ -## More info: +## Contributing + +The development is done at [GitLab](https://gitlab.com/isard/isardvdi). You can open an issue and create pull requests there. Also, there's the [CONTRIBUTING.md](CONTRIBUTING.md) file, that you should read too. Happy hacking! :D + + + +## Support and Contact + +If you have a question related with the software, open an issue! Otherwise, email us at `info@isardvdi.com`. We also offer professional paid support. If you are interested, email us! :) + + -Go to [IsardVDI Project website](http://www.isardvdi.com/) +## Other links -### Authors -+ Josep Maria Viñolas Auquer -+ Alberto Larraz Dalmases -+ Néfix Estrada +- Website: [https://www.isardvdi.com](https://www.isardvdi.com) +- Mastodon profile: [@isard@fosstodon.org](https://fosstodon.org/@isard) +- Twitter profile: [@isard_vdi](https://twitter.com/isard_vdi) -### Contributors -+ Daniel Criado Casas -### Support/Contact -Please email us at info@isardvdi.com if you have any questions or fill in an issue. -### Social Networks -Mastodon: [@isard@fosstodon.org](https://fosstodon.org/@isard) -Twitter: [@isard_vdi](https://twitter.com/isard_vdi) +## License +IsardVDI is licensed under the AGPL v3.0. You can read the full license [here](LICENSE) diff --git a/api/docker/Dockerfile b/api/docker/Dockerfile index 214476c8c..bbe64d8d0 100644 --- a/api/docker/Dockerfile +++ b/api/docker/Dockerfile @@ -13,9 +13,12 @@ RUN apk del .build_deps RUN apk add curl +COPY api/srcv2 /apiv2 COPY api/src /api - +COPY api/docker/run.sh / #EXPOSE 7039 -WORKDIR /api -CMD [ "python3", "start.py" ] +# WORKDIR /api +# CMD [ "python3", "start.py" ] + +CMD /run.sh diff --git a/api/docker/requirements.pip3 b/api/docker/requirements.pip3 index 2c7e35a2e..36ed1ddc2 100644 --- a/api/docker/requirements.pip3 +++ b/api/docker/requirements.pip3 @@ -1,16 +1,11 @@ -bcrypt==3.1.7 -cffi==1.14.0 -click==7.1.2 -Flask==1.1.2 +Flask==2.0.1 Flask-Login==0.5.0 -gevent==20.6.0 -greenlet==0.4.16 -itsdangerous==1.1.0 -Jinja2==2.11.2 -MarkupSafe==1.1.1 -pycparser==2.20 +Flask-Cors==3.0.10 +gevent==1.4.0 +gevent-websocket==0.10.1 +greenlet==0.4.15 +Flask-SocketIO==5.1.0 +python-jose==3.3.0 rethinkdb==2.4.7 -six==1.15.0 -Werkzeug==1.0.1 -zope.event==4.4 -zope.interface==5.1.0 +bcrypt==3.2.0 +responses==0.13.3 diff --git a/api/docker/run.sh b/api/docker/run.sh new file mode 100755 index 000000000..b6ffebe05 --- /dev/null +++ b/api/docker/run.sh @@ -0,0 +1,6 @@ +#!/bin/sh +export PYTHONWARNINGS="ignore:Unverified HTTPS request" +cd /apiv2 +python3 start.py & +cd /api +python3 startv3.py \ No newline at end of file diff --git a/api/src/api/__init__.py b/api/src/api/__init__.py index 4539b036c..be529ab65 100644 --- a/api/src/api/__init__.py +++ b/api/src/api/__init__.py @@ -13,14 +13,20 @@ app = Flask(__name__, static_url_path='') app.url_map.strict_slashes = False -''' -App secret key for encrypting cookies -You can generate one with: - import os - os.urandom(24) -And paste it here. -''' -app.secret_key = "Change this key!//\xf7\x83\xbe\x17\xfa\xa3zT\n\\]m\xa6\x8bF\xdd\r\xf7\x9e\x1d\x1f\x14'" +# ''' +# App secret key for encrypting cookies +# You can generate one with: +# import os +# os.urandom(24) +# And paste it here. +# ''' +# app.secret_key = "Change this key!//\xf7\x83\xbe\x17\xfa\xa3zT\n\\]m\xa6\x8bF\xdd\r\xf7\x9e\x1d\x1f\x14'" + +# Stores data for external apps poolling in ram +app.ram={'secrets':{}} + +from api.libv2.helpers import InternalUsers +app.internal_users=InternalUsers() print('Starting isard api...') @@ -28,28 +34,39 @@ cfg=loadConfig(app) if not cfg.init_app(app): exit(0) -import logging as log +import os +debug=True if os.environ['LOG_LEVEL'] == 'DEBUG' else False -''' -Debug should be removed on production! -''' -if app.debug: - log.warning('Debug mode: {}'.format(app.debug)) -else: - log.info('Debug mode: {}'.format(app.debug)) +from flask_socketio import SocketIO +# from flask_cors import CORS +# CORS(app) +# app.config["CORS_HEADERS"] = "application/json" +socketio = SocketIO( + app, + path='/api/v3/socket.io/', + cors_allowed_origins='*', + logger=debug, + engineio_logger=debug, +) + +import logging as log '''' Import all views ''' +from .views import PublicView +from .views import AdminUsersView from .views import UsersView from .views import DeploymentsView from .views import CommonView from .views import DesktopsNonPersistentView from .views import JumperViewerView from .views import DesktopsPersistentView -from .views import XmlView -from .views import SundryView +# from .views import XmlView +from .views import HypervisorsView from .views import TemplatesView +from .views import DownloadsView +from .views import DeploymentsView diff --git a/api/src/api/auth/authentication.py b/api/src/api/auth/authentication.py index 16dd558be..462cbe551 100644 --- a/api/src/api/auth/authentication.py +++ b/api/src/api/auth/authentication.py @@ -37,18 +37,22 @@ def getUser(self,username): class User(UserMixin): def __init__(self, dict): self.id = dict['id'] - self.username = dict['id'] + self.provider = dict['provider'] + self.category = dict['category'] + self.uid = dict['uid'] + self.username = dict['username'] self.name = dict['name'] self.password = dict['password'] self.role = dict['role'] - self.category = dict['category'] self.group = dict['group'] - self.path = dict['category']+'/'+dict['group']+'/'+dict['id']+'/' + self.path = dict['category']+'/'+dict['group_uid']+'/'+dict['provider']+'/'+dict['uid']+'-'+dict['username']+'/' self.email = dict['email'] self.quota = dict['quota'] self.auto = dict['auto'] if 'auto' in dict.keys() else False self.is_admin=True if self.role=='admin' else False self.active = dict['active'] + self.tags = dict.get("tags", []) + self.photo = dict['photo'] def is_active(self): return self.active @@ -136,9 +140,10 @@ def _check(self,username,password): def authentication_local(self,username,password): with app.app_context(): dbuser=r.table('users').get(username).run(db.conn) - log.info('USER:'+username) - if dbuser == None or dbuser['active'] != True: + #log.info('USER:'+username) + if dbuser is None or dbuser['active'] is not True: return False + dbuser['group_uid']=r.table('groups').get(dbuser['group']).pluck('uid').run(db.conn)['uid'] pw=Password() if pw.valid(password,dbuser['password']): #~ TODO: Check active or not user diff --git a/api/src/api/auth/tokens.py b/api/src/api/auth/tokens.py new file mode 100644 index 000000000..1f25daf20 --- /dev/null +++ b/api/src/api/auth/tokens.py @@ -0,0 +1,113 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +from functools import wraps +from api import app +import json, os +from flask import request +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +from flask import Flask, request, jsonify, _request_ctx_stack +# from flask_cors import cross_origin +from jose import jwt + +from ..libv2.log import log +import traceback + +from ..libv2.flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +# secret=os.environ['API_ISARDVDI_SECRET'] + +def get_header_jwt_payload(): + return get_token_payload(get_token_auth_header()) + +def get_token_auth_header(): + """Obtains the Access Token from the Authorization Header + """ + auth = request.headers.get("Authorization", None) + if not auth: + raise AuthError({"code": "authorization_header_missing", + "description": + "Authorization header is expected"}, 401) + + parts = auth.split() + + if parts[0].lower() != "bearer": + raise AuthError({"code": "invalid_header", + "description": + "Authorization header must start with" + " Bearer"}, 401) + elif len(parts) == 1: + raise AuthError({"code": "invalid_header", + "description": "Token not found"}, 401) + elif len(parts) > 2: + raise AuthError({"code": "invalid_header", + "description": + "Authorization header must be" + " Bearer token"}, 401) + + token = parts[1] + return token + + +def get_token_payload(token): + try: + claims=jwt.get_unverified_claims(token) + secret_data=app.ram['secrets'][claims['kid']] + # Check if the token has the correct category + if secret_data['role_id'] == 'manager' and secret_data['category_id'] != claims['data']['category_id']: + raise AuthError({"code": "unauthorized", + "description": + "Not authorized category" + " token."}, 500) + except Exception as e: + print(e) + raise AuthError({"code": "invalid_parameters", + "description": + "Unable to parse authentication parameters" + " token."}, 401) + try: + payload = jwt.decode( + token, + secret_data['secret'], + algorithms=["HS256"], + options=dict( + verify_aud=False, + verify_sub=False, + verify_exp=True, + ) + ) + except jwt.ExpiredSignatureError: + log.info('Token expired') + raise AuthError({"code": "token_expired", + "description": "token is expired"}, 401) + except jwt.JWTClaimsError: + raise AuthError({"code": "invalid_claims", + "description": + "incorrect claims," + "please check the audience and issuer"}, 401) + except Exception: + log.debug(traceback.format_exc()) + raise AuthError({"code": "invalid_header", + "description": + "Unable to parse authentication" + " token."}, 401) + if payload.get('data',False): return payload['data'] + return payload + +# Error handler +class AuthError(Exception): + def __init__(self, error, status_code): + self.error = error + self.status_code = status_code + +@app.errorhandler(AuthError) +def handle_auth_error(ex): + response = jsonify(ex.error) + response.status_code = ex.status_code + return response \ No newline at end of file diff --git a/api/src/api/libv2/api_deployments.py b/api/src/api/libv2/api_deployments.py index 6f33dd196..0d76b3d4c 100644 --- a/api/src/api/libv2/api_deployments.py +++ b/api/src/api/libv2/api_deployments.py @@ -6,8 +6,6 @@ # License: AGPLv3 import time from api import app -from datetime import datetime, timedelta -import pprint from rethinkdb import RethinkDB; r = RethinkDB() from rethinkdb.errors import ReqlTimeoutError @@ -19,30 +17,13 @@ db = RDB(app) db.init_app(app) -from ..auth.authentication import * - -from ..libv2.isardViewer import isardViewer -isardviewer = isardViewer() - -from .apiv2_exc import * - from .helpers import ( - _check, - _parse_string, - _parse_media_info, - _disk_path, - _random_password, + _parse_deployment_desktop, ) -from .ds import DS -ds = DS() - -from ..libv2.isardViewer import isardViewer -isardviewer = isardViewer() - class ApiDeployments(): def __init__(self): - self.au=auth() + None def List(self,user_id): with app.app_context(): @@ -56,31 +37,19 @@ def List(self,user_id): def Get(self,user_id,deployment_id): with app.app_context(): - if user_id != r.table('deployments').get(deployment_id).pluck('user').run(db.conn)['user']: raise - desktops = list(r.table('domains').get_all(deployment_id,index='tag').pluck('id','user','name','description','status','create_dict').merge(lambda desktop: - { - "userName": r.table('users').get(desktop['user']).pluck('name')['name'] - } - ).run(db.conn)) - for desktop in desktops: - desktop['state']=desktop.pop('status') - if desktop['state'] == 'Started': - # We only return the direct browser url. - # TODO: Check if it has RDP and send RDP instead of vnc? - desktop['viewer'] = isardviewer.viewer_data( - desktop['id'], 'vnc-html5', get_cookie=False, get_dict=True - ) - desktop["viewers"] = [] - if "default" in desktop["create_dict"]["hardware"]["videos"]: - desktop["viewers"].extend(["spice", "browser"]) - if "wireguard" in desktop["create_dict"]["hardware"]["interfaces"]: - desktop["ip"] = d.get("viewer", {}).get("guest_ip") - if not desktop["ip"]: - desktop["state"] = "WaitingIP" - if desktop["os"].startswith("win"): - desktop["viewers"].extend(["rdp", "rdp-html5"]) - desktop.pop('create_dict') - return desktops + deployment = r.table('deployments').get(deployment_id).without('create_dict').run(db.conn) + if user_id != deployment['user']: raise + desktops = list(r.table('domains').get_all(deployment_id,index='tag').pluck('id','user','name','description','status','icon','image','persistent','parents','create_dict','viewer','options').run(db.conn)) - - \ No newline at end of file + parsed_desktops=[] + for desktop in desktops: + tmp_desktop=_parse_deployment_desktop(desktop) + desktop_name = tmp_desktop.pop('name') + desktop_description = tmp_desktop.pop('description') + parsed_desktops.append(tmp_desktop) + + return {'id':deployment['id'], + 'name':deployment['name'], + 'desktop_name':desktop_name, + 'description':desktop_description, + 'desktops':parsed_desktops} diff --git a/api/src/api/libv2/api_desktops_common.py b/api/src/api/libv2/api_desktops_common.py index d8cbf93ed..e9a31a67d 100644 --- a/api/src/api/libv2/api_desktops_common.py +++ b/api/src/api/libv2/api_desktops_common.py @@ -42,7 +42,7 @@ def __init__(self): def DesktopViewer(self, desktop_id, protocol, get_cookie=False): if protocol in ['url','file']: direct_protocol = protocol - protocol = 'vnc-html5' + protocol = 'browser-vnc' else: direct_protocol = False @@ -78,11 +78,11 @@ def DesktopViewerFromToken(self, token): viewers = { "vmName": domains[0]["name"], "vmDescription": domains[0]["description"], - "spice-client": self.DesktopViewer( - domains[0]["id"], "spice-client", get_cookie=True + "file-spice": self.DesktopViewer( + domains[0]["id"], "file-spice", get_cookie=True ), - "vnc-html5": self.DesktopViewer( - domains[0]["id"], "vnc-html5", get_cookie=True + "browser-vnc": self.DesktopViewer( + domains[0]["id"], "browser-vnc", get_cookie=True ), } return viewers @@ -91,11 +91,11 @@ def DesktopViewerFromToken(self, token): viewers = { "vmName": domains[0]["name"], "vmDescription": domains[0]["description"], - "spice-client": self.DesktopViewer( - domains[0]["id"], "spice-client", get_cookie=True + "file-spice": self.DesktopViewer( + domains[0]["id"], "file-spice", get_cookie=True ), - "vnc-html5": self.DesktopViewer( - domains[0]["id"], "vnc-html5", get_cookie=True + "browser-vnc": self.DesktopViewer( + domains[0]["id"], "browser-vnc", get_cookie=True ), } return viewers diff --git a/api/src/api/libv2/api_desktops_persistent.py b/api/src/api/libv2/api_desktops_persistent.py index 7fc2c4466..836e28725 100644 --- a/api/src/api/libv2/api_desktops_persistent.py +++ b/api/src/api/libv2/api_desktops_persistent.py @@ -39,7 +39,59 @@ def Delete(self, desktop_id): raise DesktopNotFound ds.delete_desktop(desktop_id, desktop['status']) - def New(self, desktop_name, user_id, memory, vcpus, kind = 'desktop', from_template_id = False, xml_id = False, xml_definition = False, disk_size = False, disk_path = False, parent_disk_path=False, iso = False, boot='disk'): + def NewFromTemplate(self, desktop_name, template_id, payload): + with app.app_context(): + template = r.table('domains').get(template_id).run(db.conn) + if template == None: + raise TemplateNotFound + + parsed_name = _parse_string(desktop_name) + + parent_disk=template['hardware']['disks'][0]['file'] + dir_disk = payload['category_id']+'/'+payload['group_id']+'/'+payload['user_id'] + disk_filename = parsed_name+'.qcow2' + + create_dict=template['create_dict'] + create_dict['hardware']['disks']=[{'file':dir_disk+'/'+disk_filename, + 'parent':parent_disk}] + create_dict=_parse_media_info(create_dict) + + new_desktop={'id': '_'+payload['user_id']+'-'+parsed_name, + 'name': parsed_name, + 'description': template['description'], + 'kind': 'desktop', + 'user': payload['user_id'], + 'username': payload['user_id'].split('-')[-1], + 'status': 'Creating', + 'detail': None, + 'category': payload['category_id'], + 'group': payload['group_id'], + 'xml': None, + 'icon': template['icon'], + 'server': template['server'], + 'os': template['os'], + 'options': {'viewers':{'spice':{'fullscreen':True}}}, + 'create_dict': {'hardware':create_dict['hardware'], + 'origin': template['id']}, + 'hypervisors_pools': template['hypervisors_pools'], + 'allowed': {'roles': False, + 'categories': False, + 'groups': False, + 'users': False}, + 'accessed': time.time(), + 'persistent':False, + 'from_template':template['id']} + + with app.app_context(): + if r.table('domains').get(new_desktop['id']).run(db.conn) == None: + if _check(r.table('domains').insert(new_desktop).run(db.conn),'inserted') == False: + raise NewDesktopNotInserted + else: + return new_desktop['id'] + else: + raise DesktopExists + + def NewFromIso(self, desktop_name, user_id, memory, vcpus, kind = 'desktop', from_template_id = False, xml_id = False, xml_definition = False, disk_size = False, disk_path = False, parent_disk_path=False, iso = False, boot='disk'): if kind not in ['desktop', 'user_template']: raise NewDesktopNotInserted parsed_name = _parse_string(desktop_name) @@ -172,6 +224,13 @@ def Stop(self, desktop_id): raise DesktopActionFailed if desktop == None: raise DesktopNotFound - # Start the domain - ds.WaitStatus(desktop_id, 'Any', 'Shutting-down', 'Stopped') + # Stop the domain + try: + # ds.WaitStatus(desktop_id, 'Any', 'Shutting-down', 'Stopped', wait_seconds=30) + ds.WaitStatus(desktop_id, 'Any', 'Stopping', 'Stopped', wait_seconds=10) + except DesktopActionTimeout: + try: + ds.WaitStatus(desktop_id, 'Any', 'Stopping', 'Stopped') + except DesktopActionTimeout: + raise DesktopActionTimeout return desktop_id diff --git a/api/src/api/libv2/api_downloads.py b/api/src/api/libv2/api_downloads.py new file mode 100644 index 000000000..c897d21a4 --- /dev/null +++ b/api/src/api/libv2/api_downloads.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from datetime import datetime +from datetime import timedelta + +from api import app +from datetime import datetime, timedelta +import requests +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +import logging +import traceback + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +class Downloads(object): + def __init__(self): + # ~ self.working=True + self.reload_updates() + + def reload_updates(self): + self.updateFromConfig() + if not self.is_registered(): + self.register() + self.updateFromConfig() + self.updateFromWeb() + + + def updateFromWeb(self): + self.web={} + self.kinds=['media','domains','builders','virt_install','virt_builder','videos','viewers'] + failed=0 + for k in self.kinds: + self.web[k]=self.getKind(kind=k) + if self.web[k]==500: + # The id is no longer in updates server. + # We better reset it + with app.app_context(): + r.table('config').get(1).update({'resources':{'code':False}}).run(db.conn) + self.code=False + if self.private_code != False: + private_web=self.getPrivateKind(kind='private_domains') + if private_web != False: + self.web['domains']=self.web['domains'] + private_web + + # ~ if len(self.kinds)==failed: + # ~ self.working=False + + def updateFromConfig(self): + with app.app_context(): + failed=True + while failed: + try: + cfg=r.table('config').get(1).pluck('resources').run(db.conn)['resources'] + failed = False + except Exception as e: + log.warning('Waiting for database to be ready...') + time.sleep(1) + + self.url=cfg['url'] + self.code=cfg['code'] + self.private_code=False if 'private_code' not in cfg.keys() else cfg['private_code'] + + def is_conected(self): + try: + req= requests.get(self.url,allow_redirects=False, verify=False, timeout=10) + if req.status_code==200: + return True + except: + return False + return False + + def is_registered(self): + if self.is_conected(): + return self.code + # ~ if self.working: + # ~ return self.code + # ~ else: + # we have an invalid code. (changes in web database?) + # ~ with app.app_context(): + # ~ r.table('config').get(1).update({'resources':{'code':False}}).run(db.conn) + return False + + def register(self): + try: + req= requests.post(self.url+'/register',allow_redirects=False, verify=True, timeout=10) + if req.status_code==200: + with app.app_context(): + r.table('config').get(1).update({'resources':{'code':req.json()}}).run(db.conn) + self.code=req.json() + self.updateFromConfig() + self.updateFromWeb() + return True + else: + print('Error response code: '+str(req.status_code)+'\nDetail: '+r.json()) + except Exception as e: + print("Error repository register.\n"+str(e)) + return False + + def getNewKind(self,kind,username): + if kind == 'viewers': + return self.web[kind] + web=self.web[kind] + with app.app_context(): + dbb=list(r.table(kind).run(db.conn)) + result=[] + for w in web: + dict={} + found=False + for d in dbb: + if kind == 'domains' or kind == 'media': + if d['id']=='_'+username+'_'+w['id']: + dict=w.copy() + found=True + dict['id']='_'+username+'_'+dict['id'] + dict['new']=False + dict['status']=d['status'] + dict['progress']=d.get('progress',False) + break + else: + if d['id']==w['id']: + dict=w.copy() + found=True + dict['new']=False + dict['status']='Downloaded' + break + + if not found: + dict=w.copy() + if kind == 'domains' or kind == 'media': + dict['id']='_'+username+'_'+dict['id'] + dict['new']=True + dict['status']='Available' + result.append(dict) + return result + #~ return [i for i in web for j in dbb if i['id']==j['id']] + + def getNewKindId(self,kind,username,id): + if kind == 'domains' or kind == 'media': + web=[d.copy() for d in self.web[kind] if '_'+username+'_'+d['id'] == id] + else: + web=[d.copy() for d in self.web[kind] if d['id'] == id] + + if len(web)==0: return False + w=web[0].copy() + + if kind == 'domains' or kind == 'media': + with app.app_context(): + dbb=r.table(kind).get('_'+username+'_'+w['id']).run(db.conn) + if dbb is None: + w['id']='_'+username+'_'+w['id'] + return w + elif dbb.get("status") == "DownloadFailed": + return dbb + else: + with app.app_context(): + dbb=r.table(kind).get(w['id']).run(db.conn) + if dbb == None: + return w + return False + + + def getKind(self,kind='builders'): + try: + req = requests.post(self.url+'/get/'+kind+'/list', headers={'Authorization':str(self.code)},allow_redirects=False, verify=True, timeout=10) + if req.status_code==200: + return req.json() + #~ return True + elif req.status_code==500: + return 500 + else: + print('Error response code: '+str(req.status_code)+'\nDetail: '+req.json()) + except Exception as e: + print("Error repository getkinds.\n"+str(e)) + return False + + def getPrivateKind(self,kind='private_domains'): + try: + req = requests.post(self.url+'/private_get/'+kind+'/list', headers={'Authorization':str(self.code)}, json={"private_code": self.private_code}, allow_redirects=False, verify=True, timeout=10) + if req.status_code==200: + return req.json() + else: + print('Error response code: '+str(req.status_code)+'\nDetail: '+str(req.json())) + return False + except Exception as e: + print("Error repository getkind priv.\n"+str(e)) + return False + + + ''' + RETURN FORMATTED DOMAINS TO INSERT ON TABLES + ''' + # ~ def formatDomain(self,dom,current_user): + # ~ d=dom.copy() + # ~ d['progress']={} + # ~ d['status']='DownloadStarting' + # ~ d['detail']='' + # ~ d['accessed']=time.time() + # ~ d['hypervisors_pools']=d['create_dict']['hypervisors_pools'] + # ~ d.update(self.get_user_data(current_user)) + # ~ for disk in d['create_dict']['hardware']['disks']: + # ~ if not disk['file'].startswith(current_user.path): + # ~ disk['file']=current_user.path+disk['file'] + # ~ return d + + def formatDomains(self,data,user_id): + new_data=data.copy() + for d in new_data: + d['progress']={} + d['status']='DownloadStarting' + d['detail']='' + d['accessed']=time.time() + d['hypervisors_pools']=d['create_dict']['hypervisors_pools'] + user=self.get_user_data(user_id) + d.update({'user':user['id'], + 'category':user['category'], + 'group':user['group'], + 'username':user['username']}) + path=self.get_user_path(user) + for disk in d['create_dict']['hardware']['disks']: + if not disk['file'].startswith(path): + disk['file']=path+disk['file'] + return new_data + + def formatMedias(self,data,current_user): + new_data=data.copy() + for d in new_data: + d.update(self.get_user_data(current_user)) + d['progress']={} + d['status']='DownloadStarting' + d['accessed']=time.time() + if d['url-isard'] == False: + d['path']=current_user.path+d['url-web'].split('/')[-1] + else: + d['path']=current_user.path+d['url-isard'] + # ~ if not d['path'].startswith(current_user.path): + # ~ d['path']=current_user.path+d['url-isard'] + return new_data + + def get_user_data(self,user_id): + with app.app_context(): + user= r.table('users').get(user_id).run(db.conn) + return {'provider':user['provider'], + 'category': user['category'], + 'group': user['group'], + 'id': user['id'], + 'uid': user['uid'], + 'username': user['username']} + + def get_user_path(self,user): + return user['category']+'/'+user['group']+'/'+user['provider']+'/'+user['uid']+'-'+user['username']+'/' + + + ''' + DOWNLOAD MISSING DOMAIN RESOURCES + ''' + def get_missing_resources(self,domain,username): + missing_resources={'videos':[]} + + dom_videos=domain['create_dict']['hardware']['videos'] + with app.app_context(): + sys_videos=list(r.table('videos').pluck('id').run(db.conn)) + sys_videos=[sv['id'] for sv in sys_videos] + for v in dom_videos: + if v not in sys_videos: + resource=self.getNewKindId('videos',username,v) + if resource != False: + missing_resources['videos'].append(resource) + ## graphics and interfaces missing + return missing_resources + + def download_desktop(self,desktop_id,user_id): + with app.app_context(): + if r.table('domains').get(desktop_id).run(db.conn) is not None: return False + desktop=self.formatDomains([self.getNewKindId('domains',user_id,desktop_id)],user_id)[0] + with app.app_context(): + r.table('domains').insert(desktop).run(db.conn) + return True \ No newline at end of file diff --git a/api/src/api/libv2/api_socketio_deployments.py b/api/src/api/libv2/api_socketio_deployments.py new file mode 100644 index 000000000..cd0178d09 --- /dev/null +++ b/api/src/api/libv2/api_socketio_deployments.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time, os +from api import app +from datetime import datetime, timedelta +from pprint import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +from .log import log +import json +import traceback + +from rethinkdb.errors import ReqlDriverError +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from flask import request +from flask_socketio import SocketIO, emit, join_room, leave_room, \ + close_room, rooms, disconnect, send +import threading + +from flask_socketio import SocketIO + +from .. import socketio + +threads = {} + +from flask import Flask, request, jsonify, _request_ctx_stack +# from flask_cors import cross_origin + +from ..auth.tokens import get_token_payload, AuthError + +from .helpers import ( + _parse_desktop, +) + +## deployments Threading +class DeploymentsThread(threading.Thread): + def __init__(self): + threading.Thread.__init__(self) + self.stop = False + + def run(self): + while True: + try: + with app.app_context(): + for c in r.table('deployments').changes(include_initial=False).run(db.conn): + if self.stop==True: break + if c['new_val'] == None: + event='delete' + user = c['old_val']['user'] + deployment={'id':c['old_val']['id']} + elif c['old_val'] == None: + event='add' + user = c['new_val']['user'] + deployment={'id':c['new_val']['id'], + 'name':c['new_val']['name'], + 'user':user, + 'totalDesktops': r.table('domains').get_all(deployment['id'],index='tag').count().run(db.conn), + "startedDesktops": 0 + } + else: + continue + socketio.emit('deployment_'+event, + json.dumps(deployment), + namespace='/userspace', + room='deployments_'+user) + + except ReqlDriverError: + print('DeploymentsThread: Rethink db connection lost!') + log.error('DeploymentsThread: Rethink db connection lost!') + time.sleep(.5) + except Exception: + print('DeploymentsThread internal error: restarting') + log.error('DeploymentsThread internal error: restarting') + log.error(traceback.format_exc()) + time.sleep(2) + + print('DeploymentsThread ENDED!!!!!!!') + log.error('DeploymentsThread ENDED!!!!!!!') + +def start_deployments_thread(): + global threads + if 'deployments' not in threads: threads['deployments']=None + if threads['deployments'] == None: + threads['deployments'] = DeploymentsThread() + threads['deployments'].daemon = True + threads['deployments'].start() + log.info('DeploymentsThread Started') + +# # deployments namespace +# @socketio.on('connect', namespace='/deployments') +# def socketio_deployments_connect(): +# try: +# payload = get_token_payload(request.args.get('jwt')) +# if payload['role_id'] == 'advanced': +# join_room(payload['user_id']) +# log.debug('User '+payload['user_id']+' joined deployments ws') +# except: +# log.debug('Failed attempt to connect so socketio: '+traceback.format_exc()) + +# @socketio.on('disconnect', namespace='/deployments') +# def socketio_deployments_disconnect(): +# try: +# payload = get_token_payload(request.args.get('jwt')) +# leave_room(payload['user_id']) +# except: +# pass diff --git a/api/src/api/libv2/api_socketio_domains.py b/api/src/api/libv2/api_socketio_domains.py new file mode 100644 index 000000000..186cf127e --- /dev/null +++ b/api/src/api/libv2/api_socketio_domains.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time, os +from api import app +from datetime import datetime, timedelta +from pprint import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +from .log import log +import json +import traceback + +from rethinkdb.errors import ReqlDriverError +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from flask import request +from flask_socketio import SocketIO, emit, join_room, leave_room, \ + close_room, rooms, disconnect, send +import threading + +from flask_socketio import SocketIO + +from .. import socketio + +threads = {} + +from flask import Flask, request, jsonify, _request_ctx_stack +# from flask_cors import cross_origin + +from ..auth.tokens import get_token_payload, AuthError + +from .helpers import ( + _parse_desktop, + _parse_deployment_desktop, +) + +## Domains Threading +class DomainsThread(threading.Thread): + def __init__(self): + threading.Thread.__init__(self) + self.stop = False + + def run(self): + last_deployment=None + while True: + try: + with app.app_context(): + for c in r.table('domains').pluck( + [ + "id", + "name", + "icon", + "image", + "user", + "status", + "description", + "parents", + "persistent", + "os", + "tag_visible", + "viewer", + "options", + {"create_dict": {"hardware": ["interfaces", "videos"]}}, + "kind", + "tag", + ] + ).changes(include_initial=False).run(db.conn): + if self.stop==True: break + + if c['new_val'] == None: + # Delete + if not c['old_val']['id'].startswith('_'): continue + event='delete' + if c['old_val']['kind'] != 'desktop': + item='template' + else: + item='desktop' + data=c['old_val'] + else: + if not c['new_val']['id'].startswith('_'): continue + if c['new_val']['status'] not in ['Creating','Shutting-down','Stopping','Stopped','Starting','Started','Failed']: continue + if c['new_val']['kind'] != 'desktop': + item='template' + else: + item='desktop' + + if c['old_val'] == None: + # New + event='add' + else: + # Update + event='update' + data=c['new_val'] + + socketio.emit(item+'_'+event, + json.dumps(data if item != 'desktop' else _parse_desktop(data)), + namespace='/userspace', + room=item+'s_'+data['user']) + + # Event delete for users when tag becomes hidden + if not data.get("tag_visible", True): + if c["old_val"] is None or c["old_val"].get("tag_visible"): + socketio.emit('desktop_delete', + json.dumps(data if item != 'desktop' else _parse_desktop(data)), + namespace='/userspace', + room=item+'s_'+data['user']) + + ## Tagged desktops to advanced users + if data['kind']=='desktop' and data.get("tag", False): + deployment_id = data.get("tag") + data=_parse_deployment_desktop(data) + data.pop('name') + data.pop('description') + socketio.emit('deploymentdesktop_'+event, + json.dumps(data), + namespace='/userspace', + room='deploymentdesktops_'+deployment_id) + + ## And then update deployments to user owner (if the deployment still exists) + try: + deployment = r.table('deployments').get(deployment_id).pluck('id','name','user').merge(lambda d: + { + "totalDesktops": r.table('domains').get_all(d['id'],index='tag').count(), + "startedDesktops": r.table('domains').get_all(d['id'],index='tag').filter({'status':'Started'}).count() + } + ).run(db.conn) + if last_deployment == deployment: + continue + else: + last_deployment = deployment + socketio.emit('deployment_update', + json.dumps(deployment), + namespace='/userspace', + room='deployments_'+deployment['user']) + except: + None + + + except ReqlDriverError: + print('DomainsThread: Rethink db connection lost!') + log.error('DomainsThread: Rethink db connection lost!') + time.sleep(.5) + except Exception: + print('DomainsThread internal error: restarting') + log.error('DomainsThread internal error: restarting') + log.error(traceback.format_exc()) + time.sleep(2) + + print('DomainsThread ENDED!!!!!!!') + log.error('DomainsThread ENDED!!!!!!!') + +def start_domains_thread(): + global threads + if 'domains' not in threads: threads['domains']=None + if threads['domains'] == None: + threads['domains'] = DomainsThread() + threads['domains'].daemon = True + threads['domains'].start() + log.info('DomainsThread Started') + +# Domains namespace +@socketio.on('connect', namespace='/userspace') +def socketio_users_connect(): + try: + payload = get_token_payload(request.args.get('jwt')) + + room = request.args.get('room') + ## Rooms: desktop, deployment, deploymentdesktop + if room == 'deploymentdesktops': + with app.app_context(): + if r.table('deployments').get(request.args.get('deploymentId')).run(db.conn)['user'] != payload['user_id']: raise + deployment_id = request.args.get('deploymentId') + join_room('deploymentdesktops_'+deployment_id) + else: + join_room(room+'_'+payload['user_id']) + log.debug('User '+payload['user_id']+' joined room: '+room) + except: + log.debug('Failed attempt to connect so socketio: '+traceback.format_exc()) + +@socketio.on('disconnect', namespace='/userspace') +def socketio_domains_disconnect(): + try: + payload = get_token_payload(request.args.get('jwt')) + for room in ['desktops','deployments','deployment_deskstop']: + leave_room(room+'_'+payload['user_id']) + except: + pass \ No newline at end of file diff --git a/api/src/api/libv2/api_socketio_secrets.py b/api/src/api/libv2/api_socketio_secrets.py new file mode 100644 index 000000000..40a8cfd7b --- /dev/null +++ b/api/src/api/libv2/api_socketio_secrets.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time, os +from api import app +from datetime import datetime, timedelta +from pprint import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +from .log import log +import json +import traceback + +from rethinkdb.errors import ReqlDriverError +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +import threading + +threads = {} + +from flask import Flask, request, jsonify, _request_ctx_stack +# from flask_cors import cross_origin + + +## secrets Threading +class SecretsThread(threading.Thread): + def __init__(self): + threading.Thread.__init__(self) + self.stop = False + + def run(self): + while True: + try: + with app.app_context(): + for c in r.table('secrets').changes(include_initial=True).run(db.conn): + if self.stop==True: break + + if not c.get('old_val',False): + # Its initial loading to app.ram + app.ram['secrets'][c['new_val']['id']]=c['new_val'] + # Continue if we don't want initial to be passed to clients + continue + + if c['new_val'] == None: + del app.ram['secrets'][c['old_val']['id']] + else: + app.ram['secrets'][c['new_val']['id']]=c['new_val'] + + except ReqlDriverError: + print('SecretsThread: Rethink db connection lost!') + log.error('SecretsThread: Rethink db connection lost!') + time.sleep(.5) + except Exception: + print('SecretsThread internal error: restarting') + log.error('SecretsThread internal error: restarting') + log.error(traceback.format_exc()) + time.sleep(2) + + print('SecretsThread ENDED!!!!!!!') + log.error('SecretsThread ENDED!!!!!!!') + +def start_secrets_thread(): + global threads + if 'secrets' not in threads: threads['secrets']=None + if threads['secrets'] == None: + threads['secrets'] = SecretsThread() + threads['secrets'].daemon = True + threads['secrets'].start() + log.info('SecretsThread Started') + +# secrets namespace +# @socketio.on('connect', namespace='/userspace') +# def socketio_secrets_connect(): +# try: +# payload = get_token_payload(request.args.get('jwt')) +# join_room(payload['user_id']) +# log.debug('User '+payload['user_id']+' joined userspace ws') +# except: +# log.debug('Failed attempt to connect so socketio: '+traceback.format_exc()) + +# @socketio.on('disconnect', namespace='/userspace') +# def socketio_secrets_disconnect(): +# try: +# payload = get_token_payload(request.args.get('jwt')) +# leave_room(payload['user_id']) +# except: +# pass diff --git a/api/src/api/libv2/api_sundry.py b/api/src/api/libv2/api_sundry.py index d824df3d7..d6d944b1c 100644 --- a/api/src/api/libv2/api_sundry.py +++ b/api/src/api/libv2/api_sundry.py @@ -39,4 +39,4 @@ def __init__(self): def UpdateGuestAddr(self, domain_id, data): with app.app_context(): if not _check(r.table('domains').get(domain_id).update(data).run(db.conn),'replaced'): - raise UpdateFailed + raise UpdateFailed diff --git a/api/src/api/libv2/api_templates.py b/api/src/api/libv2/api_templates.py index be33a6193..4a3e0e796 100644 --- a/api/src/api/libv2/api_templates.py +++ b/api/src/api/libv2/api_templates.py @@ -28,10 +28,15 @@ class ApiTemplates(): def __init__(self): None + def New(self, template_name, desktop_id, allowed_roles=False, allowed_categories=False, allowed_groups=False, allowed_users=False): + allowed={'roles': allowed_roles, + 'categories': allowed_categories, + 'groups': allowed_groups, + 'users': allowed_users} - def New(self, template_name, user_id, from_desktop_id): parsed_name = _parse_string(template_name) + user_id=desktop_id.split('_')[1] template_id = '_' + user_id + '-' + parsed_name with app.app_context(): @@ -39,7 +44,7 @@ def New(self, template_name, user_id, from_desktop_id): user=r.table('users').get(user_id).pluck('id','category','group','provider','username','uid').run(db.conn) except: raise UserNotFound - desktop = r.table('domains').get(from_desktop_id).run(db.conn) + desktop = r.table('domains').get(desktop_id).run(db.conn) if desktop == None: raise DesktopNotFound parent_disk=desktop['hardware']['disks'][0]['file'] @@ -51,8 +56,8 @@ def New(self, template_name, user_id, from_desktop_id): 'parent':parent_disk}] create_dict=_parse_media_info({'hardware':hardware}) - create_dict['origin']=from_desktop_id - print(create_dict) + create_dict['origin']=desktop_id + template_dict={'id': template_id, 'name': template_name, 'description': 'Api created', @@ -71,15 +76,12 @@ def New(self, template_name, user_id, from_desktop_id): 'create_dict': create_dict, 'hypervisors_pools': ['default'], 'parents': desktop['parents'] if 'parents' in desktop.keys() else [], - 'allowed': {'roles': False, - 'categories': False, - 'groups': False, - 'users': False}} + 'allowed': allowed} with app.app_context(): if r.table('domains').get(template_dict['id']).run(db.conn) == None: - if _check(r.table('domains').get(from_desktop_id).update({"create_dict": {"template_dict": template_dict}, "status": "CreatingTemplate"}).run(db.conn),'replaced') == False: + if _check(r.table('domains').get(desktop_id).update({"create_dict": {"template_dict": template_dict}, "status": "CreatingTemplate"}).run(db.conn),'replaced') == False: raise NewTemplateNotInserted else: return template_dict['id'] @@ -92,8 +94,11 @@ def Get(self,template_id): try: return r.table('domains').get(template_id).pluck('id','name','icon','image','description').run(db.conn) except: - return UserTemplatesError + raise UserTemplatesError + def Delete(self,template_id): + ## TODO: Delete all related desktops!!! + ds.delete_desktop(template_id, 'Stopped') diff --git a/api/src/api/libv2/api_users.py b/api/src/api/libv2/api_users.py index 3e207e11a..3f810500b 100644 --- a/api/src/api/libv2/api_users.py +++ b/api/src/api/libv2/api_users.py @@ -10,7 +10,7 @@ import pprint from rethinkdb import RethinkDB; r = RethinkDB() -from rethinkdb.errors import ReqlTimeoutError +from rethinkdb.errors import ReqlTimeoutError, ReqlNonExistenceError import logging import traceback @@ -32,11 +32,17 @@ _parse_media_info, _disk_path, _random_password, + _parse_desktop, ) from .ds import DS ds = DS() +from jose import jwt +import os +import bcrypt +import secrets +import requests def check_category_domain(category_id, domain): with app.app_context(): @@ -54,11 +60,47 @@ class ApiUsers(): def __init__(self): self.au=auth() - def Login(self,user_id,user_passwd): - user=self.au._check(user_id,user_passwd) - if user == False: + def Jwt(self,user_id): + # user_id = provider_id+'-'+category_id+'-'+id+'-'+id + try: + with app.app_context(): + user = r.table('users').get(user_id).pluck('id','username','photo','email','role','category','group').run(db.conn) + user = {'user_id':user['id'], + 'role_id':user['role'], + 'category_id':user['category'], + 'group_id':user['group'], + 'username':user['username'], + 'email':user['email'], + 'photo':user['photo']} + except: + raise UserNotFound + return {'jwt':jwt.encode({"exp": datetime.utcnow() + timedelta(hours=4), + "kid":"isardvdi", + "data":user}, + app.ram['secrets']['isardvdi']['secret'], + algorithm='HS256')} + + def Login(self,user_id,user_passwd, provider='local', category_id='default'): + with app.app_context(): + user = r.table('users').get(user_id).run(db.conn) + if user is None or not user.get('active',False): raise UserLoginFailed - return user.id + + pw=Password() + if pw.valid(user_passwd,user['password']): + user = {'user_id':user['id'], + 'role_id':user['role'], + 'category_id':user['category'], + 'group_id':user['group'], + 'username':user['username'], + 'email':user['email'], + 'photo':user['photo']} + return user_id,jwt.encode({"exp": datetime.utcnow() + timedelta(hours=4), + "kid":"isardvdi", + "data":user}, + app.ram['secrets']['isardvdi']['secret'], + algorithm='HS256') + raise UserLoginFailed def Exists(self,user_id): with app.app_context(): @@ -67,6 +109,10 @@ def Exists(self,user_id): raise UserNotFound return user + def List(self): + with app.app_context(): + return list(r.table('users').without('password','vpn').run(db.conn)) + def Create(self, provider, category_id, user_uid, user_username, name, role_id, group_id, password=False, encrypted_password=False, photo='', email=''): # password=False generates a random password with app.app_context(): @@ -126,60 +172,57 @@ def Update(self, user_id, user_name=False, user_email=False, user_photo=False): ): raise UpdateFailed - def Templates(self,user_id): - with app.app_context(): - if r.table('users').get(user_id).run(db.conn) == None: - raise UserNotFound + def Templates(self,payload): try: with app.app_context(): - ud=r.table('users').get(user_id).run(db.conn) - if ud == None: - raise UserNotFound - with app.app_context(): - data1 = r.table('domains').get_all('base', index='kind').order_by('name').pluck({'id','name','allowed','kind','group','icon','user','description'}).run(db.conn) - data2 = r.table('domains').filter(r.row['kind'].match("template")).order_by('name').pluck({'id','name','allowed','kind','group','icon','user','description'}).run(db.conn) - data = data1+data2 + data1 = r.table('domains').get_all('base', index='kind').order_by('name').pluck({'id','name','allowed','kind','category','group','icon','user','description'}).run(db.conn) + data2 = r.table('domains').filter(r.row['kind'].match("template")).order_by('name').pluck({'id','name','allowed','kind','category','group','icon','user','description'}).run(db.conn) + desktops = data1+data2 alloweds=[] - for d in data: - with app.app_context(): - d['username']=r.table('users').get(d['user']).pluck('name').run(db.conn)['name'] - if ud['role']=='admin': - alloweds.append(d) + for desktop in desktops: + # with app.app_context(): + # desktop['username']=r.table('users').get(desktop['user']).pluck('name').run(db.conn)['name'] + if payload['role_id']=='admin': + alloweds.append(desktop) + continue + if payload['role_id']=='manager' and payload['category_id'] == desktop['category']: + alloweds.append(desktop) continue - if d['user']==ud['id']: - alloweds.append(d) + if not payload.get('user_id',False): continue + if desktop['user']==payload['user_id']: + alloweds.append(desktop) continue - if d['allowed']['roles'] is not False: - if len(d['allowed']['roles'])==0: - alloweds.append(d) + if desktop['allowed']['roles'] is not False: + if len(desktop['allowed']['roles'])==0: + alloweds.append(desktop) continue else: - if ud['role'] in d['allowed']['roles']: - alloweds.append(d) + if payload['role_id'] in desktop['allowed']['roles']: + alloweds.append(desktop) continue - if d['allowed']['categories'] is not False: - if len(d['allowed']['categories'])==0: - alloweds.append(d) + if desktop['allowed']['categories'] is not False: + if len(desktop['allowed']['categories'])==0: + alloweds.append(desktop) continue else: - if ud['category'] in d['allowed']['categories']: - alloweds.append(d) + if payload['category_id'] in desktop['allowed']['categories']: + alloweds.append(desktop) continue - if d['allowed']['groups'] is not False: - if len(d['allowed']['groups'])==0: - alloweds.append(d) + if desktop['allowed']['groups'] is not False: + if len(desktop['allowed']['groups'])==0: + alloweds.append(desktop) continue else: - if ud['group'] in d['allowed']['groups']: - alloweds.append(d) + if payload['group_id'] in desktop['allowed']['groups']: + alloweds.append(desktop) continue - if d['allowed']['users'] is not False: - if len(d['allowed']['users'])==0: - alloweds.append(d) + if desktop['allowed']['users'] is not False: + if len(desktop['allowed']['users'])==0: + alloweds.append(desktop) continue else: - if ud['id'] in d['allowed']['users']: - alloweds.append(d) + if payload['user_id'] in desktop['allowed']['users']: + alloweds.append(desktop) continue return alloweds except Exception as e: @@ -215,31 +258,63 @@ def Desktops(self,user_id): ) .run(db.conn) ) - modified_desktops = [] - for d in desktops: - if not d.get("tag_visible", True): - continue - d["image"] = d.get("image", None) - d["from_template"] = d.get("parents", [None])[-1] - if d.get("persistent", True): - d["type"] = "persistent" + return [_parse_desktop(desktop) for desktop in desktops if desktop.get("tag_visible", True)] + except Exception as e: + error = traceback.format_exc() + logging.error(error) + raise UserDesktopsError + + def Desktop(self,desktop_id,user_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) == None: + raise UserNotFound + try: + with app.app_context(): + desktop = r.table("domains") \ + .get(desktop_id) \ + .pluck( + [ + "id", + "name", + "icon", + "image", + "user", + "status", + "description", + "parents", + "persistent", + "os", + "tag_visible", + {"viewer": "guest_ip"}, + {"create_dict": {"hardware": ["interfaces", "videos"]}}, + ] + ) \ + .run(db.conn) + + # Modify desktop data to be returned + if desktop.get("tag_visible", True): + + # if desktop["status"] not in ["Started", "Failed","Stopped"]: + # desktop["status"] = "Working" + desktop["image"] = desktop.get("image", None) + desktop["from_template"] = desktop.get("parents", [None])[-1] + if desktop.get("persistent", True): + desktop["type"] = "persistent" else: - d["type"] = "nonpersistent" - d["viewers"] = [] - if d["status"] == "Started": - if any( - item in d["create_dict"]["hardware"]["videos"] - for item in ["default", "vga"] - ): - d["viewers"].extend(["spice", "browser"]) - if "wireguard" in d["create_dict"]["hardware"]["interfaces"]: - d["ip"] = d.get("viewer", {}).get("guest_ip") - if not d["ip"]: - d["status"] = "WaitingIP" - if d["os"].startswith("win"): - d["viewers"].extend(["rdp", "rdp-html5"]) - modified_desktops.append(d) - return modified_desktops + desktop["type"] = "nonpersistent" + desktop["viewers"] = ["file-spice", "browser-vnc"] + if desktop["status"] == "Started": + if "wireguard" in desktop["create_dict"]["hardware"]["interfaces"]: + desktop["ip"] = desktop.get("viewer", {}).get("guest_ip") + if not desktop["ip"]: + desktop["status"] = "WaitingIP" + if desktop["os"].startswith("win"): + desktop["viewers"].extend(["file-rdpvpn", "browser-rdp"]) + return desktop + else: + return None + except ReqlNonExistenceError: + raise DesktopNotFound except Exception as e: error = traceback.format_exc() logging.error(error) @@ -250,9 +325,9 @@ def Delete(self,user_id): if r.table('users').get(user_id).run(db.conn) is None: raise UserNotFound todelete = self._user_delete_checks(user_id) - for d in todelete: + for desktop in todelete: try: - ds.delete_desktop(d['id'],d['status']) + ds.delete_desktop(desktop['id'],desktop['status']) except: raise #self._delete_non_persistent(user_id) @@ -268,10 +343,17 @@ def _user_delete_checks(self,user_id): id = ut['id'] derivated = derivated + list(r.table('domains').pluck('id','name','kind','user','status','parents').filter(lambda derivates: derivates['parents'].contains(id)).run(db.conn)) #templates = [t for t in derivated if t['kind'] != "desktop"] - #desktops = [d for d in derivated if d['kind'] == "desktop"] + #desktops = [d for d in derivated if desktop['kind'] == "desktop"] domains = user_desktops+user_templates+derivated return [i for n, i in enumerate(domains) if i not in domains[n + 1:]] + def OwnsDesktop(self, user_id, guess_ip): + with app.app_context(): + ips = list(r.table("domains").get_all(user_id, index='user').pluck({'viewer':'guest_ip'}).run(db.conn)) + if len([ip for ip in ips if ip.get('viewer', False) and ip['viewer'].get('guest_ip',False) == guess_ip]): + return True + raise DesktopNotFound + def CodeSearch(self,code): with app.app_context(): found=list(r.table('groups').filter({'enrollment':{'manager':code}}).run(db.conn)) @@ -289,17 +371,16 @@ def CodeSearch(self,code): raise CodeNotFound def CategoryGet(self,category_id): - with app.app_context(): + with app.app_context(): category = r.table('categories').get(category_id).run(db.conn) if category is None: raise CategoryNotFound - return { 'name': category['name'] } ### USER Schema - def CategoryCreate(self,category_name,group_name=False,category_limits=False,category_quota=False,group_quota=False): + def CategoryCreate(self,category_name,frontend=False,group_name=False,category_limits=False,category_quota=False,group_quota=False): category_id=_parse_string(category_name) if group_name: group_id=_parse_string(group_name) @@ -314,7 +395,8 @@ def CategoryCreate(self,category_name,group_name=False,category_limits=False,cat "id": category_id , "limits": category_limits , "name": category_name , - "quota": category_quota + "quota": category_quota, + "frontend": frontend } r.table('categories').insert(category, conflict='update').run(db.conn) @@ -355,5 +437,64 @@ def GroupCreate(self,category_id,group_name,category_limits=False,category_quota return category_id+'-'+group_id def CategoriesGet(self): + with app.app_context(): + return list(r.table('categories').pluck({'id','name','frontend'}).order_by('name').run(db.conn)) + + def CategoriesFrontendGet(self): with app.app_context(): return list(r.table('categories').pluck({'id','name','frontend'}).filter({'frontend':True}).order_by('name').run(db.conn)) + + def CategoryDelete(self, category_id): + #### TODO: Delete all desktops, templates and users in category + with app.app_context(): + r.table('users').get_all(category_id,index='category').delete().run(db.conn) + r.table('groups').get_all(category_id,index='parent_category').delete().run(db.conn) + return r.table('categories').get(category_id).delete().run(db.conn) + + def GroupsGet(self): + with app.app_context(): + return list(r.table('groups').order_by('name').run(db.conn)) + + def GroupDelete(self, group_id): + #### TODO: Delete all desktops, templates and users in category + with app.app_context(): + r.table('users').get_all(group_id,index='group').delete().run(db.conn) + return r.table('groups').get(group_id).delete().run(db.conn) + + def Secret(self,kid,description,role_id,category_id,domain): + with app.app_context(): + ## TODO: Check if exists, check that role is correct and category exists + secret = secrets.token_urlsafe(32) + r.table('secrets').insert({'id':kid, + 'secret':secret, + 'description':description, + 'role_id':role_id, + 'category_id':category_id, + 'domain':domain}).run(db.conn) + return secret + + def SecretDelete(self,kid): + with app.app_context(): + ## TODO: Check if exists, check that role is correct and category exists + secret = secrets.token_urlsafe(32) + r.table('secrets').get(kid).delete().run(db.conn) + return True + +''' +PASSWORDS MANAGER +''' +import bcrypt,string,random +class Password(object): + def __init__(self): + None + + def valid(self,plain_password,enc_password): + return bcrypt.checkpw(plain_password.encode('utf-8'), enc_password.encode('utf-8')) + + def encrypt(self,plain_password): + return bcrypt.hashpw(plain_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + def generate_human(self,length=6): + chars = string.ascii_letters + string.digits + '!@#$*' + rnd = random.SystemRandom() + return ''.join(rnd.choice(chars) for i in range(length)) \ No newline at end of file diff --git a/api/src/api/libv2/ds.py b/api/src/api/libv2/ds.py index e906981f5..c2140ab18 100644 --- a/api/src/api/libv2/ds.py +++ b/api/src/api/libv2/ds.py @@ -82,9 +82,9 @@ def delete_non_persistent(self, user_id, template=False): for desktop in desktops_to_delete: ds.delete_desktop(desktop['id'],desktop['status']) - def WaitStatus(self, desktop_id, original_status, transition_status, final_status): + def WaitStatus(self, desktop_id, original_status, transition_status, final_status, wait_seconds=10): with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(lambda p: self._wait_for_domain_status(*p), [desktop_id, original_status, transition_status, final_status]) + future = executor.submit(lambda p: self._wait_for_domain_status(*p), [desktop_id, original_status, transition_status, final_status, wait_seconds]) try: result = future.result() except ReqlTimeoutError: @@ -93,7 +93,7 @@ def WaitStatus(self, desktop_id, original_status, transition_status, final_statu raise DesktopActionFailed return True - def _wait_for_domain_status(self, desktop_id, original_status, transition_status, final_status): + def _wait_for_domain_status(self, desktop_id, original_status, transition_status, final_status, wait_seconds): with app.app_context(): # Prepare changes if final_status == 'Deleted': @@ -112,11 +112,11 @@ def _wait_for_domain_status(self, desktop_id, original_status, transition_status # Get change try: - doc = changestatus.next(wait=5) + doc = changestatus.next(wait=wait_seconds) except ReqlTimeoutError: raise if final_status != 'Deleted': - if doc['new_val']['status'] == 'Failed': + if doc['new_val']['status'] != final_status: raise DesktopWaitFailed def _check(self,dict,action): diff --git a/api/src/api/libv2/helpers.py b/api/src/api/libv2/helpers.py index 82e11f2fe..48bc3fe49 100644 --- a/api/src/api/libv2/helpers.py +++ b/api/src/api/libv2/helpers.py @@ -23,6 +23,37 @@ from .apiv2_exc import * +from ..libv2.isardViewer import isardViewer +isardviewer = isardViewer() + +import traceback + +class InternalUsers(object): + def __init__(self): + self.users={} + + def get(self,user_id): + data=self.users.get(user_id,False) + if not data: + with app.app_context(): + try: + user = r.table('users').get(user_id).pluck('name','category','group','photo').run(db.conn) + category_name = r.table('categories').get(user['category']).pluck('name').run(db.conn)['name'] + group_name = r.table('groups').get(user['group']).pluck('name').run(db.conn)['name'] + self.users[user_id] = {'userName':user['name'], + 'userPhoto': user['photo'], + 'categoryName':category_name, + 'groupName':group_name} + except: + print(traceback.format_exc()) + return {'userName':'Unknown', + 'userPhoto': 'Unknown', + 'categoryName':'Unknown', + 'groupName':'Unknown'} + return self.users[user_id] + + def list(self): + return self.users def _parse_string( txt): import re, unicodedata, locale @@ -71,3 +102,51 @@ def _parse_media_info( create_dict): create_dict['hardware'][m]=newlist return create_dict +def _parse_desktop(desktop): + desktop["image"] = desktop.get("image", None) + desktop["from_template"] = desktop.get("parents", [None])[-1] + if desktop.get("persistent", True): + desktop["type"] = "persistent" + else: + desktop["type"] = "nonpersistent" + desktop["viewers"] = ["file-spice", "browser-vnc"] + if desktop["status"] == "Started": + if "wireguard" in desktop["create_dict"]["hardware"]["interfaces"]: + desktop["ip"] = desktop.get("viewer", {}).get("guest_ip") + if not desktop["ip"]: + desktop["status"] = "WaitingIP" + if desktop["os"].startswith("win"): + desktop["viewers"].extend(["file-rdpvpn", "browser-rdp"]) + + return { + "id": desktop["id"], + "name": desktop["name"], + "state": desktop["status"], + "type": desktop["type"], + "template": desktop["from_template"], + "viewers": desktop["viewers"], + "icon": desktop["icon"], + "image": desktop["image"], + "description": desktop["description"], + "ip": desktop.get("ip"), + } + +def _parse_deployment_desktop(desktop): + user = desktop['user'] + if desktop['status'] == 'Started' and desktop.get('viewer').get('static'): + viewer = isardviewer.viewer_data( + desktop['id'], 'browser-vnc', get_cookie=False, get_dict=True, domain=desktop + ) + else: + viewer = False + desktop = _parse_desktop(desktop) + desktop['viewer'] = viewer + desktop = {**desktop, **app.internal_users.get(user)} + desktop['user'] = user + desktop.pop('type') + desktop.pop('template') + + return desktop + + + diff --git a/api/src/api/libv2/isardViewer.py b/api/src/api/libv2/isardViewer.py index 090f1105e..8714bc044 100644 --- a/api/src/api/libv2/isardViewer.py +++ b/api/src/api/libv2/isardViewer.py @@ -4,23 +4,6 @@ # License: AGPLv3 #!/usr/bin/env python -# coding=utf-8 -# ~ import sys, json -# ~ from webapp import app -# ~ import rethinkdb as r -# ~ from ..lib.log import * - -# ~ from .flask_rethink import RethinkDB -# ~ db = RethinkDB(app) -# ~ db.init_app(app) - -# ~ from .admin_api import flatten -# ~ from netaddr import IPNetwork, IPAddress - -# ~ from ..lib.viewer_exc import * - -# ~ from http.cookies import SimpleCookie -# ~ import base64 import sys,base64,json import os @@ -49,11 +32,12 @@ def __init__(self): self.vnc_ws=-198 #5900-200???? pass - def viewer_data(self,id,get_viewer='spice-client',current_user=False,default_viewer=False,get_cookie=True,get_dict=False): - try: - domain = r.table('domains').get(id).pluck('id','name','status','viewer','options').run(db.conn) - except ReqlNonExistenceError: - raise DesktopNotFound + def viewer_data(self,id,get_viewer='browser-vnc',current_user=False,default_viewer=False,get_cookie=True,get_dict=False,domain=False): + if not domain: + try: + domain = r.table('domains').get(id).pluck('id','name','status','viewer','options').run(db.conn) + except ReqlNonExistenceError: + raise DesktopNotFound if not domain['status'] == 'Started': raise DesktopNotStarted @@ -62,102 +46,96 @@ def viewer_data(self,id,get_viewer='spice-client',current_user=False,default_vie raise NotAllowed if 'preferred' not in domain['options']['viewers'].keys() or not domain['options']['viewers']['preferred'] == default_viewer: r.table('domains').get(id).update({'options':{'viewers':{'preferred':default_viewer}}}).run(db.conn) - - if get_viewer == 'rdp-client': - return {'kind':'file','name':'isard-rdp','ext':'rdp','mime':'application/x-rdp','content':self.get_rdp_file(domain['viewer']['guest_ip'])} - if get_viewer == 'spice-html5': - if get_cookie: - cookie = base64.b64encode(json.dumps({ - 'web_viewer': { - 'vmName': domain['name'], - 'vmHost': domain['viewer']['proxy_hyper_host'], - 'vmPort': str(domain['viewer']['base_port']+self.spice), - 'host': domain['viewer']['proxy_video'], - 'port': '443', - 'token': domain['viewer']['passwd'] - } - }).encode('utf-8')).decode('utf-8') - uri = 'https://'+domain['viewer']['static']+'/viewer/spice-web-client/', - return {'kind':'url','viewer':uri,'cookie':cookie} - else: - return 'https://'+domain['viewer']['static']+'/viewer/spice-web-client/?vmName='+urllib.parse.quote_plus(domain['name'])+'&vmHost='+domain['viewer']['proxy_hyper_host']+'&host='+domain['viewer']['proxy_video']+'&vmPort='+str(port)+'&passwd='+domain['viewer']['passwd'] - - if get_viewer == 'vnc-html5': - vmPort=str(domain['viewer']['base_port']+self.vnc) - port=str(domain['viewer']['html5_ext_port']) if 'html5_ext_port' in domain['viewer'].keys() else '443' - if get_cookie: - cookie = base64.b64encode(json.dumps({ - 'web_viewer': { - 'vmName': domain['name'], - 'vmHost': domain['viewer']['proxy_hyper_host'], - 'vmPort': vmPort, - 'host': domain['viewer']['proxy_video'], - 'port': port, - 'token': domain['viewer']['passwd'] - } - }).encode('utf-8')).decode('utf-8') - uri = 'https://'+domain['viewer']['static']+'/viewer/noVNC/', - return {'kind':'url','viewer':uri,'cookie':cookie} - elif get_dict: - return { - 'proxy': 'https://'+domain['viewer']['static']+'/viewer/noVNC/', - 'vmName': domain['name'], - 'vmHost': domain['viewer']['proxy_hyper_host'], - 'vmPort': vmPort, - 'host': domain['viewer']['proxy_video'], - 'port': port, - 'token': domain['viewer']['passwd'] - } - else: - return 'https://'+domain['viewer']['static']+'/viewer/noVNC/?vmName='+urllib.parse.quote_plus(domain['name'])+'&vmHost='+domain['viewer']['proxy_hyper_host']+'&host='+domain['viewer']['proxy_video']+'&port='+port+'&vmPort='+vmPort+'&passwd='+domain['viewer']['passwd'] - - if get_viewer == "rdp-html5": - vmPort = str(domain["viewer"]["base_port"] + self.vnc) - port = ( - str(domain["viewer"]["html5_ext_port"]) - if "html5_ext_port" in domain["viewer"].keys() - else "443" - ) - if get_cookie: - cookie = base64.b64encode( - json.dumps( - { - "web_viewer": { - "vmName": domain["name"], - "vmHost": domain["viewer"]["guest_ip"], - "vmUsername": domain["options"]["credentials"][ - "username" - ] - if "credentials" in domain["options"] - else "", - "vmPassword": domain["options"]["credentials"][ - "password" - ] - if "credentials" in domain["options"] - else "", - "host": domain["viewer"]["proxy_video"], - "port": port, - } - } - ).encode("utf-8") - ).decode("utf-8") - uri = (f"https://{domain['viewer']['static']}/Rdp",) - return {"kind": "url", "viewer": uri, "cookie": cookie} - else: - return "https://" + domain["viewer"]["static"] + "/notavailable" - - if get_viewer == 'spice-client': + ### File viewers + if get_viewer == 'file-spice': port=domain['viewer']['base_port']+self.spice_tls - vmPort=domain['viewer']['spice_ext_port'] if 'spice_ext_port' in domain['viewer'].keys() else '80' + vmPort=domain['viewer'].get('spice_ext_port','80') consola=self.get_spice_file(domain,vmPort,port) - if get_cookie: - return {'kind':'file','name':'isard-spice','ext':consola[0],'mime':consola[1],'content':consola[2]} - else: - return consola[2] + return {'kind':'file', + 'protocol':'spice', + 'name':'isard-spice', + 'ext':consola[0], + 'mime':consola[1], + 'content':consola[2]} - if get_viewer == 'vnc-client': + if get_viewer == 'file-vnc': raise ViewerProtocolNotImplemented + + if get_viewer == 'file-rdpvpn': + return {'kind':'file', + 'protocol':'rdpvpn', + 'name':'isard-rdp-vpn', + 'ext':'rdp', + 'mime':'application/x-rdp', + 'content':self.get_rdp_file(domain['viewer']['guest_ip'])} + + ## Browser viewers + if get_viewer == 'browser-spice': + data = { + 'vmName': domain['name'], + 'vmHost': domain['viewer']['proxy_hyper_host'], + 'vmPort': str(domain['viewer']['base_port']+self.spice), + 'host': domain['viewer']['proxy_video'], + 'port': domain['viewer'].get('html5_ext_port','443'), + 'token': domain['viewer']['passwd'] + } + cookie = base64.b64encode(json.dumps({"web_viewer": data}).encode('utf-8')).decode('utf-8') + uri = 'https://'+domain['viewer']['static']+'/viewer/spice-web-client/' + urlp = 'https://'+domain['viewer']['static']+'/viewer/spice-web-client/?vmName='+urllib.parse.quote_plus(domain['name'])+'&vmHost='+domain['viewer']['proxy_hyper_host']+'&host='+domain['viewer']['proxy_video']+'&vmPort='+data['port']+'&passwd='+domain['viewer']['passwd'] + return {'kind':'browser', + 'protocol':'spice', + 'viewer':uri, + 'urlp': urlp, + 'cookie':cookie, + 'values': data} + + if get_viewer == 'browser-vnc': + data = { + 'vmName': domain['name'], + 'vmHost': domain['viewer']['proxy_hyper_host'], + 'vmPort': str(domain['viewer']['base_port']+self.vnc), + 'host': domain['viewer']['proxy_video'], + 'port': domain['viewer'].get('html5_ext_port','443'), + 'token': domain['viewer']['passwd'] + } + cookie = base64.b64encode(json.dumps({"web_viewer": data}).encode('utf-8')).decode('utf-8') + uri = 'https://'+domain['viewer']['static']+'/viewer/noVNC/' + urlp = 'https://'+domain['viewer']['static']+'/viewer/noVNC/?vmName='+urllib.parse.quote_plus(domain['name'])+'&vmHost='+domain['viewer']['proxy_hyper_host']+'&host='+domain['viewer']['proxy_video']+'&port='+data['port']+'&vmPort='+data['vmPort']+'&passwd='+domain['viewer']['passwd'] + return {'kind':'browser', + 'protocol':'vnc', + 'viewer':uri, + 'urlp': urlp, + 'cookie':cookie, + 'values': data} + + if get_viewer == "browser-rdp": + data = { + 'vmName': domain['name'], + 'vmHost': domain["viewer"]["guest_ip"], + "vmUsername": domain["options"]["credentials"][ + "username" + ] + if "credentials" in domain["options"] + else "", + "vmPassword": domain["options"]["credentials"][ + "password" + ] + if "credentials" in domain["options"] + else "", + 'host': domain['viewer']['proxy_video'], + 'port': domain['viewer'].get('html5_ext_port','443') + } + cookie = base64.b64encode(json.dumps({"web_viewer": data}).encode('utf-8')).decode('utf-8') + uri = (f"https://{domain['viewer']['static']}/Rdp",) + urlp = 'Not implemented' + return {'kind':'browser', + 'protocol':'rdp', + 'viewer':uri, + 'urlp': urlp, + 'cookie':cookie, + 'values': data} + if get_viewer == 'vnc-client-macos': raise ViewerProtocolNotImplemented @@ -172,17 +150,7 @@ def get_spice_file(self, domain, port, vmPort): op_fscr = 1 if domain['options'] is not False and domain['options']['fullscreen'] else 0 except: op_fscr = 0 - - # ~ viewer = { 'viewer_static_host': hypervisor['viewer_static_host'], - # ~ 'viewer_proxy_video': hypervisor['viewer_proxy_video'], - # ~ 'viewer_hyper_host': hypervisor['viewer_hyper_host'], - # ~ 'base_port': domain['viewer']['port'], - # ~ 'passwd': domain['viewer']['passwd'], - # ~ 'client_addr': False, - # ~ 'client_since': False, - # ~ } - - + c = '%' consola = """[virt-viewer] type=%s diff --git a/api/src/api/libv2/load_config.py b/api/src/api/libv2/load_config.py index 180ea1a08..eb10a7fc2 100644 --- a/api/src/api/libv2/load_config.py +++ b/api/src/api/libv2/load_config.py @@ -11,27 +11,57 @@ from api import app # ~ import rethinkdb as r -#~ from flask import current_app +#~ from flask import app # ~ from .flask_rethink import RethinkDB -import os, sys +import os, sys, time, traceback import logging as log +from rethinkdb import RethinkDB; r = RethinkDB() +#import rethinkdb as r + class loadConfig(): def __init__(self, app=None): None def check_db(self): - return True - try: - conn=RethinkDB(None) - conn.connect() - return True - except Exception as e: - print(e) - return False - + ready=False + while not ready: + try: + conn=r.connect(host=app.config['RETHINKDB_HOST'], + port=app.config['RETHINKDB_PORT'], + auth_key='', + db=app.config['RETHINKDB_DB']) + print('Database server OK') + ready=True + except Exception as e: + # print(traceback.format_exc()) + print('Database server '+app.config['RETHINKDB_HOST']+':'+app.config['RETHINKDB_PORT']+' not present. Waiting to be ready') + time.sleep(2) + ready=False + while not ready: + try: + tables = list(r.db('isard').table_list().run(conn)) + except: + print(' No tables yet in database') + time.sleep(1) + continue + if 'config' in tables: + ready=True + else: + print('Waiting for database to be populated with all tables...') + print(' '+str(len(tables))+' populated') + time.sleep(2) + r.db('isard').table('secrets').insert( + {'id':'isardvdi', + 'secret': os.environ['API_ISARDVDI_SECRET'], + 'description': 'isardvdi', + 'domain': 'localhost', + "category_id":"default", + "role_id":"admin"}, conflict="replace" + ).run(conn) + def init_app(self, app): ''' Read RethinkDB configuration from environ @@ -54,8 +84,5 @@ def init_app(self, app): print('Missing parameters!') return False print('Initial configuration loaded...') - if self.check_db() is False: - print('No database found!!!!!!!!!!') - print('Using database connection {} and database {}'.format(app.config['RETHINKDB_HOST']+':'+app.config['RETHINKDB_PORT'],app.config['RETHINKDB_DB'])) - return False + self.check_db() return True diff --git a/api/src/api/templates/jumper.html b/api/src/api/templates/jumper.html index b0c7a0fbb..c794e7ecc 100644 --- a/api/src/api/templates/jumper.html +++ b/api/src/api/templates/jumper.html @@ -63,8 +63,8 @@

IsardVDI

Direct viewer connection

{{ vmName|safe }}

{{ vmDescription|safe }}
- - + + @@ -87,15 +87,15 @@
{{ vmDescription|safe }}
function open_viewer(kind,blank='_blank'){ var viewers = {{ viewers|safe }}; - if(kind=='url'){ - setCookie('browser_viewer', viewers['vnc-html5']['cookie'], 1) - window.open(viewers['vnc-html5']['viewer'], blank); + if(kind=='browser-vnc'){ + setCookie('browser_viewer', viewers['browser-vnc']['cookie'], 1) + window.open(viewers['browser-vnc']['viewer'], blank); } - if(kind=='file'){ - var viewerFile = new Blob([viewers['spice-client']['content']], {type: viewers['spice-client']['mime']}); + if(kind=='file-spice'){ + var viewerFile = new Blob([viewers['file-spice']['content']], {type: viewers['file-spice']['mime']}); var a = document.createElement('a'); - a.download = 'console.'+viewers['spice-client']['ext']; + a.download = 'console.'+viewers['file-spice']['ext']; a.href = window.URL.createObjectURL(viewerFile); var ev = document.createEvent("MouseEvents"); ev.initMouseEvent("click", true, false, self, 0, 0, 0, 0, 0, false, false, false, false, 0, null); diff --git a/api/src/api/views/AdminUsersView.py b/api/src/api/views/AdminUsersView.py new file mode 100644 index 000000000..141dd1fae --- /dev/null +++ b/api/src/api/views/AdminUsersView.py @@ -0,0 +1,473 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log +import traceback + +from uuid import uuid4 +import time,json +import sys,os +from flask import request, jsonify +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +from ..libv2.quotas import Quotas +quotas = Quotas() + +from ..libv2.api_users import ApiUsers, check_category_domain +users = ApiUsers() + +from ..libv2.isardVpn import isardVpn +vpn = isardVpn() + +from .decorators import has_token, is_admin, ownsUserId, ownsCategoryId + +@app.route('/api/v3/admin/jwt/', methods=['GET']) +@has_token +def api_v3_admin_jwt(payload, user_id): + if ownsUserId(payload,user_id): return users.Jwt(user_id) + return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/user/', methods=['GET']) +@has_token +def api_v3_admin_user_exists(payload, id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + if not ownsUserId(payload,id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + try: + user=users.Exists(id) + return json.dumps(user), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"User not exists in database"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserExists general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/users', methods=['GET']) +@has_token +def api_v3_admin_users(payload): + try: + userslist = users.List() + except: + log.error(traceback.format_exc()) + return json.dumps({"code":9,"msg":"Users list general exception: " + traceback.format_exc() }), 401, {'Content-Type': 'application/json'} + + if payload['role_id'] == 'admin': return json.dumps(userslist), 200, {'Content-Type': 'application/json'} + if payload['role_id'] == 'manager': + filtered_users=[u for u in userslit if u['category'] == payload['category_id']] + return json.dumps(filtered_users), 200, {'Content-Type': 'application/json'} + return json.dumps({"code":10,"msg":"Forbidden" }), 403, {'Content-Type': 'application/json'} + +# Update user name +@app.route('/api/v3/admin/user/', methods=['PUT']) +@has_token +def api_v3_admin_user_update(payload, id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + if not ownsUserId(payload,id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + try: + name = request.form.get("name", "") + email = request.form.get("email", "") + photo = request.form.get("photo", "") + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + + if name == False and email == False and photo == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query. At least one parameter should be specified." }), 401, {'Content-Type': 'application/json'} + try: + users.Update(id,user_name=name,user_email=email,user_photo=photo) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except UpdateFailed: + log.error("User "+id+" update failed.") + return json.dumps({"code":1,"msg":"User update failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserUpdate general exception: " + error }), 401, {'Content-Type': 'application/json'} + +# Add user +@app.route('/api/v3/admin/user', methods=['POST']) +@has_token +def api_v3_admin_user_insert(payload): + try: + # Required + provider = request.form.get('provider', type = str) + user_uid = request.form.get('user_uid', type = str) + user_username = request.form.get('user_username', type = str) + role_id = request.form.get('role_id', type = str) + category_id = request.form.get('category_id', type = str) + group_id = request.form.get('group_id', type = str) + + # Optional + name=request.form.get('name', user_username, type = str) + password = request.form.get('password', False, type = str) + encrypted_password = request.form.get('encrypted_password', False, type = str) + photo = request.form.get('photo', '', type = str) + email = request.form.get('email', '', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + if provider == None or user_username == None or role_id == None or category_id == None or group_id == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + if password == None: password = False + + if not ownsCategoryId(payload,category_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + try: + quotas.UserCreate(category_id,group_id) + except QuotaCategoryNewUserExceeded: + log.error("Quota for creating another user in category "+category_id+" is exceeded") + return json.dumps({"code":11,"msg":"UserNew category quota for adding user exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewUserExceeded: + log.error("Quota for creating another user in group "+group_id+" is exceeded") + return json.dumps({"code":11,"msg":"UserNew group quota for adding user exceeded"}), 507, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} + + + try: + user_id=users.Create( provider, \ + category_id, \ + user_uid, \ + user_username, \ + name, \ + role_id, \ + group_id, \ + password, \ + encrypted_password, \ + photo, \ + email) + return json.dumps({'id':user_id}), 200, {'Content-Type': 'application/json'} + except UserExists: + user_id = provider+'-'+category_id+'-'+user_uid+'-'+user_username + return json.dumps({'id':user_id}), 200, {'Content-Type': 'application/json'} + except RoleNotFound: + log.error("Role "+role_username+" not found.") + return json.dumps({"code":2,"msg":"Role not found"}), 404, {'Content-Type': 'application/json'} + except CategoryNotFound: + log.error("Category "+category_id+" not found.") + return json.dumps({"code":3,"msg":"Category not found"}), 404, {'Content-Type': 'application/json'} + except GroupNotFound: + log.error("Group "+group_id+" not found.") + return json.dumps({"code":4,"msg":"Group not found"}), 404, {'Content-Type': 'application/json'} + except NewUserNotInserted: + log.error("User "+user_username+" could not be inserted into database.") + return json.dumps({"code":5,"msg":"User could not be inserted into database. Already exists!"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserUpdate general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/user/', methods=['DELETE']) +@has_token +def api_v3_admin_user_delete(payload, user_id): + + if not ownsUserId(payload,user_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + try: + users.Delete(user_id) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User delete "+user_id+", user not found") + return json.dumps({"code":1,"msg":"User delete id not found"}), 404, {'Content-Type': 'application/json'} + except UserDeleteFailed: + log.error("User delete "+user_id+", user delete failed") + return json.dumps({"code":2,"msg":"User delete failed"}), 404, {'Content-Type': 'application/json'} + except DesktopDeleteFailed: + log.error("User delete for user "+user_id+", desktop delete failed") + return json.dumps({"code":5,"msg":"User delete, desktop deleting failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserDelete general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/templates', methods=['GET']) +@has_token +def api_v3_admin_templates(payload): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + templates = users.Templates(payload) + dropdown_templates = [{'id':t['id'],'name':t['name'],'icon':t['icon'],'image':'','description':t['description']} for t in templates] + return json.dumps(dropdown_templates), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+payload['user_id']+" not in database.") + return json.dumps({"code":1,"msg":"UserTemplates: User not exists in database"}), 404, {'Content-Type': 'application/json'} + except UserTemplatesError: + log.error("Template list for user "+payload['user_id']+" failed.") + return json.dumps({"code":2,"msg":"UserTemplates: list error"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserTemplates general exception: " + error }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v3/admin/user//templates', methods=['GET']) +@has_token +def api_v3_admin_user_templates(payload,id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + if not ownsUserId(payload,id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + + try: + templates = users.Templates(id) + dropdown_templates = [{'id':t['id'],'name':t['name'],'icon':t['icon'],'image':'','description':t['description']} for t in templates] + return json.dumps(dropdown_templates), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"UserTemplates: User not exists in database"}), 404, {'Content-Type': 'application/json'} + except UserTemplatesError: + log.error("Template list for user "+id+" failed.") + return json.dumps({"code":2,"msg":"UserTemplates: list error"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserTemplates general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/user//desktops', methods=['GET']) +@has_token +def api_v3_admin_user_desktops(payload,id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + if not ownsUserId(payload,id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + try: + desktops = users.Desktops(id) + dropdown_desktops = [ + { + "id": d["id"], + "name": d["name"], + "state": d["status"], + "type": d["type"], + "template": d["from_template"], + "viewers": d["viewers"], + "icon": d["icon"], + "image": d["image"], + "description": d["description"], + "ip": d.get("ip"), + } + for d in desktops + ] + return json.dumps(dropdown_desktops), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"UserDesktops: User not exists in database"}), 404, {'Content-Type': 'application/json'} + except UserDesktopsError: + log.error("Desktops list for user "+id+" failed.") + return json.dumps({"code":2,"msg":"UserDesktops: list error"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserDesktops general exception: " + error }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v3/admin/category/', methods=['GET']) +@is_admin +def api_v3_admin_category(id,payload): + try: + data = users.CategoryGet(id) + return json.dumps(data), 200, {'Content-Type': 'application/json'} + except CategoryNotFound: + return json.dumps({"code":1,"msg":"Category "+id+" not exists in database"}), 404, {'Content-Type': 'application/json'} + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"Register general exception: " + error }), 500, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/category', methods=['POST']) +@is_admin +def api_v3_admin_category_insert(payload): + try: + # Required + category_name = request.form.get('category_name', type = str) + + # Optional + frontend = request.form.get('frontend', False) + if frontend == 'False': frontend = False + if frontend == 'True': frontend = True + group_name = request.form.get('group_name', False) + category_limits = request.form.get('category_limits', False) + if category_limits == 'False': category_limits = False + if category_limits != False: category_limits=json.loads(category_limits) + category_quota = request.form.get('category_quota', False) + if category_quota == 'False': category_quota = False + if category_quota != False: category_quota=json.loads(category_quota) + group_quota = request.form.get('group_quota', False) + if group_quota == 'False': group_quota = False + if group_quota != False: group_quota=json.loads(group_quota) + + ## We should check here if limits and quotas have a correct dict schema + + ## + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + if category_name == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + category_id=users.CategoryCreate( category_name, + frontend=frontend, + group_name=group_name, + category_limits=category_limits, + category_quota=category_quota, + group_quota=group_quota) + return json.dumps({'id':category_id}), 200, {'Content-Type': 'application/json'} + except Exception as e: + log.error("Category create error.") + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"General exception when creating category pair: "+error}), 401, {'Content-Type': 'application/json'} + +# Add group +@app.route('/api/v3/admin/group', methods=['POST']) +@has_token +def api_v3_admin_group_insert(payload): + try: + # Required + category_id = request.form.get('category_id', type = str) + group_name = request.form.get('group_name', type = str) + + # Optional + category_limits = request.form.get('category_limits', False) + if category_limits == 'False': category_limits = False + if category_limits != False: category_limits=json.loads(category_limits) + category_quota = request.form.get('category_quota', False) + if category_quota == 'False': category_quota = False + if category_quota != False: category_quota=json.loads(category_quota) + group_quota = request.form.get('group_quota', False) + if group_quota == 'False': group_quota = False + if group_quota != False: group_quota=json.loads(group_quota) + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + if category_id == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + if not ownsCategoryId(payload,category_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + ## We should check here if limits and quotas have a correct dict schema + + ## + + try: + group_id=users.GroupCreate( category_id, \ + group_name, + category_limits=category_limits, + category_quota=category_quota, + group_quota=group_quota) + return json.dumps({'id':group_id}), 200, {'Content-Type': 'application/json'} + except Exception as e: + log.error(" Group create error.") + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"General exception when creating group: "+error}), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v3/admin/categories', methods=['GET']) +@app.route('/api/v3/admin/categories/', methods=['GET']) +@is_admin +def api_v3_admin_categories(payload,frontend=False): + try: + if not frontend: + return json.dumps(users.CategoriesGet()), 200, {'Content-Type': 'application/json'} + else: + return json.dumps(users.CategoriesFrontendGet()), 200, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"CategoriesGet general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/category/', methods=['DELETE']) +@is_admin +def api_v3_admin_category_delete(category_id,payload): + try: + return json.dumps(users.CategoryDelete(category_id)), 200, {'Content-Type': 'application/json'} + except: + log.error(traceback.format_exc()) + return json.dumps({"code":9,"msg":"CategoryDelete general exception: " + traceback.format_exc() }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v3/admin/groups', methods=['GET']) +@has_token +def api_v3_admin_groups(payload): + if payload['role_id'] not in ['admin','manager']: return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + groups=users.GroupsGet() + if payload['role_id'] == 'admin': return json.dumps(groups), 200, {'Content-Type': 'application/json'} + try: + filtered_groups = [g for g in groups if g['parent_category']==payload['category_id']] + return json.dumps(filtered_groups), 200, {'Content-Type': 'application/json'} + except: + log.error(traceback.format_exc()) + return json.dumps({"code":9,"msg":"GroupsGet general exception: " + traceback.format_exc() }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/group/', methods=['DELETE']) +@is_admin +def api_v3_admin_group_delete(group_id,payload): + try: + return json.dumps(users.GroupDelete(group_id)), 200, {'Content-Type': 'application/json'} + except: + log.error(traceback.format_exc()) + return json.dumps({"code":9,"msg":"GroupDelete general exception: " + traceback.format_exc() }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v3/admin/user//vpn//', methods=['GET']) +@app.route('/api/v3/admin/user//vpn/', methods=['GET']) +# kind = config,install +# os = +@has_token +def api_v3_admin_user_vpn(payload, id, kind, os=False): + if not ownsUserId(payload,id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + if not os and kind != "config": + return ( + json.dumps({"code": 9, "msg": "UserVpn: no OS supplied"}), + 401, + {"Content-Type": "application/json"}, + ) + + vpn_data = vpn.vpn_data("users", kind, os, id) + + if vpn_data: + return json.dumps(vpn_data), 200, {"Content-Type": "application/json"} + else: + return ( + json.dumps({"code": 9, "msg": "UserVpn no VPN data"}), + 401, + {"Content-Type": "application/json"}, + ) + +@app.route('/api/v3/admin/secret', methods=['POST']) +@is_admin +def api_v3_admin_secret(payload): + try: + # Required + kid = request.form.get('kid', type = str) + description = request.form.get('description', '') + role_id = request.form.get('role_id', type = str) + category_id = request.form.get('category_id', type = str) + domain = request.form.get('domain', type = str) + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + + if role_id == None or domain == None or kid == None or category_id == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + secret = users.Secret(kid,description,role_id,category_id,domain) + return json.dumps({'secret':secret}), 200, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/secret/', methods=['DELETE']) +@is_admin +def api_v3_admin_secret_delete(payload,kid): + users.SecretDelete(kid) + return json.dumps({}), 200, {'Content-Type': 'application/json'} \ No newline at end of file diff --git a/api/src/api/views/CommonView.py b/api/src/api/views/CommonView.py index 4eb79c3a9..5eb634a8f 100644 --- a/api/src/api/views/CommonView.py +++ b/api/src/api/views/CommonView.py @@ -26,14 +26,6 @@ send_from_directory, ) -# from ..libv2.telegram import tsend -def tsend(txt): - None - - -from ..libv2.carbon import Carbon - -carbon = Carbon() from ..libv2.quotas import Quotas @@ -43,9 +35,11 @@ def tsend(txt): common = ApiDesktopsCommon() +from .decorators import has_token, is_admin, ownsUserId, ownsCategoryId, ownsDomainId, allowedTemplateId -@app.route("/api/v2/desktop//viewer/", methods=["GET"]) -def api_v2_desktop_viewer(desktop_id=False, protocol=False): +@app.route("/api/v3/desktop//viewer/", methods=["GET"]) +@has_token +def api_v3_desktop_viewer(payload,desktop_id=False, protocol=False): if desktop_id == False or protocol == False: log.error("Incorrect access parameters. Check your query.") return ( @@ -56,6 +50,7 @@ def api_v2_desktop_viewer(desktop_id=False, protocol=False): {"Content-Type": "application/json"}, ) + if not ownsDomainId(payload,desktop_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} try: viewer = common.DesktopViewer(desktop_id, protocol, get_cookie=True) return json.dumps(viewer), 200, {"Content-Type": "application/json"} @@ -133,12 +128,14 @@ def api_v2_desktop_viewer(desktop_id=False, protocol=False): ) @app.route("/api/v2/desktop//viewers", methods=["GET"]) -def api_v2_desktop_viewers(desktop_id=False, protocol=False): +@has_token +def api_v2_desktop_viewers(payload,desktop_id=False, protocol=False): + if not ownsDomainId(payload,desktop_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} viewers = [] - for protocol in ['vnc-html5','spice-client']: + for protocol in ['browser-vnc','file-spice']: try: viewer = common.DesktopViewer(desktop_id, protocol, get_cookie=True) - viewers.append({**{'protocol':protocol},**viewer}) + viewers.append(viewer) except DesktopNotFound: log.error( "Viewer for desktop " diff --git a/api/src/api/views/DeploymentsView.py b/api/src/api/views/DeploymentsView.py index f3c0dda4d..9f4332ca5 100644 --- a/api/src/api/views/DeploymentsView.py +++ b/api/src/api/views/DeploymentsView.py @@ -16,34 +16,32 @@ from ..libv2.apiv2_exc import * from ..libv2.quotas_exc import * -#from ..libv2.telegram import tsend -def tsend(txt): - None -from ..libv2.carbon import Carbon -carbon = Carbon() - from ..libv2.quotas import Quotas quotas = Quotas() from ..libv2.api_deployments import ApiDeployments deployments = ApiDeployments() +from .decorators import has_token, is_admin, ownsUserId, ownsCategoryId, ownsDomainId, allowedTemplateId -@app.route('/api/v2/user//deployment/', methods=['GET']) -def api_v2_deployment(user_id,deployment_id): +@app.route('/api/v3/deployment/', methods=['GET']) +@has_token +def api_v3_deployment(payload,deployment_id): try: - deployment = deployments.Get(user_id,deployment_id) + deployment = deployments.Get(payload['user_id'],deployment_id) return json.dumps(deployment), 200, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() return json.dumps({"code":9,"msg":"DeploymentGet general exception: " + error }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/user//deployments', methods=['GET']) -def api_v2_deployments(user_id): + +@app.route('/api/v3/deployments', methods=['GET']) +@has_token +def api_v3_deployments(payload): try: - deployments_list = deployments.List(user_id) + deployments_list = deployments.List(payload['user_id']) return json.dumps(deployments_list), 200, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() diff --git a/api/src/api/views/DesktopsNonPersistentView.py b/api/src/api/views/DesktopsNonPersistentView.py index 20c2b6097..0072f47de 100644 --- a/api/src/api/views/DesktopsNonPersistentView.py +++ b/api/src/api/views/DesktopsNonPersistentView.py @@ -16,22 +16,19 @@ from ..libv2.apiv2_exc import * from ..libv2.quotas_exc import * -#from ..libv2.telegram import tsend -def tsend(txt): - None -from ..libv2.carbon import Carbon -carbon = Carbon() - from ..libv2.quotas import Quotas quotas = Quotas() from ..libv2.api_desktops_nonpersistent import ApiDesktopsNonPersistent desktops = ApiDesktopsNonPersistent() -@app.route('/api/v2/desktop', methods=['POST']) -def api_v2_desktop_new(): +from .decorators import has_token, is_admin, ownsUserId, ownsCategoryId, ownsDomainId, allowedTemplateId + +@app.route('/api/v3/desktop', methods=['POST']) +@has_token +def api_v3_desktop_new(payload): try: - user_id = request.form.get('id', type = str) + user_id = payload['user_id'] template_id = request.form.get('template', type = str) except Exception as e: return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} @@ -39,6 +36,7 @@ def api_v2_desktop_new(): log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + if not allowedTemplateId(payload,template_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} # Leave only one nonpersistent desktop from this template try: desktops.DeleteOthers(user_id,template_id) @@ -135,9 +133,7 @@ def api_v2_desktop_new(): # So now we have checked if desktop exists and if we can create and/or start it try: - now=time.time() desktop_id = desktops.New(user_id,template_id) - carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} except UserNotFound: log.error("Desktop for user "+user_id+" from template "+template_id+", user not found") @@ -147,11 +143,9 @@ def api_v2_desktop_new(): return json.dumps({"code":2,"msg":"DesktopNew template not found"}), 404, {'Content-Type': 'application/json'} except DesktopNotCreated: log.error("Desktop for user "+user_id+" from template "+template_id+" creation failed.") - carbon.send({'create_and_start_time':'100'}) return json.dumps({"code":1,"msg":"DesktopNew not created"}), 404, {'Content-Type': 'application/json'} except DesktopActionTimeout: log.error("Desktop for user "+user_id+" from template "+template_id+" start timeout.") - carbon.send({'create_and_start_time':'100'}) return ( json.dumps({"code": 2, "msg": "DesktopNew start timeout"}), 408, @@ -159,22 +153,21 @@ def api_v2_desktop_new(): ) except DesktopActionFailed: log.error("Desktop for user "+user_id+" from template "+template_id+" start failed.") - carbon.send({'create_and_start_time':'100'}) return json.dumps({"code":3,"msg":"DesktopNew start failed"}), 404, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() return json.dumps({"code":9,"msg":"DesktopNew general exception: " + error }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/desktop/', methods=['DELETE']) -def api_v2_desktop_delete(desktop_id=False): +@app.route('/api/v3/desktop/', methods=['DELETE']) +@has_token +def api_v3_desktop_delete(payload, desktop_id=False): if desktop_id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + if not ownsDomainId(payload,desktop_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} try: - now=time.time() desktops.Delete(desktop_id) - carbon.send({'delete_time':str(round(time.time()-now,2))}) return json.dumps({}), 200, {'Content-Type': 'application/json'} except DesktopNotFound: log.error("Desktop delete "+desktop_id+", desktop not found") diff --git a/api/src/api/views/DesktopsPersistentView.py b/api/src/api/views/DesktopsPersistentView.py index f8bae2278..5f61ab0e0 100644 --- a/api/src/api/views/DesktopsPersistentView.py +++ b/api/src/api/views/DesktopsPersistentView.py @@ -16,112 +16,22 @@ from ..libv2.apiv2_exc import * from ..libv2.quotas_exc import * -#from ..libv2.telegram import tsend -def tsend(txt): - None -from ..libv2.carbon import Carbon -carbon = Carbon() - from ..libv2.quotas import Quotas quotas = Quotas() from ..libv2.api_desktops_persistent import ApiDesktopsPersistent desktops = ApiDesktopsPersistent() -@app.route('/api/v2/persistent_desktop', methods=['POST']) -def api_v2_persistent_desktop_new(): - try: - name = request.form.get('name', type = str) - user_id = request.form.get('user_id', type = str) - memory = request.form.get('memory', type = float) - vcpus = request.form.get('vcpus', type = int) - - kind=request.form.get('kind', 'desktop') - template_id = request.form.get('template_id', False) - if template_id == 'False': template_id = False - xml_id = request.form.get('xml_id', False) - xml_definition = request.form.get('xml_definition', False) - disk_size = request.form.get('disk_size', False) - disk_path = request.form.get('disk_path', False) - parent_disk_path = request.form.get('parent_disk_path', False) - iso = request.form.get('iso', False) - boot = request.form.get('template_id', 'disk', type = str) - except Exception as e: - return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} - - if user_id == None or name == None or vcpus == None or memory == None: - log.error("Incorrect access parameters. Check your query.") - return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} - - try: - quotas.DesktopCreate(user_id) - except QuotaUserNewDesktopExceeded: - log.error("Quota for user "+user_id+" for creating another desktop is exceeded") - return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaGroupNewDesktopExceeded: - log.error("Quota for user "+user_id+" group for creating another desktop is exceeded") - return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaCategoryNewDesktopExceeded: - log.error("Quota for user "+user_id+" category for creating another desktop is exceeded") - return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} - except Exception as e: - error = traceback.format_exc() - return json.dumps({"code":9,"msg":"PersistentDesktopNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} - - try: - now=time.time() - #desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,xml_id=xml_id, disk_size=disk_size) - - desktop_id = desktops.New(name, - user_id, - memory, - vcpus, - kind=kind, - from_template_id=template_id, - xml_id=xml_id, - xml_definition=xml_definition, - disk_size=disk_size, - disk_path=disk_path, - parent_disk_path=parent_disk_path, - iso=iso, - boot=boot) - carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) - return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} - except UserNotFound: - log.error("Desktop for user "+user_id+" from template "+template_id+", user not found") - return json.dumps({"code":1,"msg":"PersistentDesktopNew user not found"}), 404, {'Content-Type': 'application/json'} - except TemplateNotFound: - log.error("Desktop for user "+user_id+" from template "+template_id+" template not found.") - return json.dumps({"code":2,"msg":"PersistentDesktopNew template not found"}), 404, {'Content-Type': 'application/json'} - except DesktopExists: - log.error("Desktop "+name+" for user "+user_id+" already exists") - return json.dumps({"code":3,"msg":"PersistentDesktopNew desktop already exists"}), 404, {'Content-Type': 'application/json'} - except DesktopNotCreated: - log.error("Desktop for user "+user_id+" from template "+template_id+" creation failed.") - carbon.send({'create_and_start_time':'100'}) - return json.dumps({"code":4,"msg":"PersistentDesktopNew not created"}), 404, {'Content-Type': 'application/json'} - ### Needs more! - except Exception as e: - error = traceback.format_exc() - return json.dumps({"code":9,"msg":"PersistentDesktopNew general exception: " + error }), 401, {'Content-Type': 'application/json'} - - - #except DesktopActionTimeout: - # log.error("Desktop delete "+desktop_id+", desktop stop timeout") - # return json.dumps({"code":2,"msg":"Desktop delete stopping timeout"}), 404, {'Content-Type': 'application/json'} - #except DesktopActionFailed: - # log.error("Desktop delete "+desktop_id+", desktop stop failed") - # return json.dumps({"code":3,"msg":"Desktop delete stopping failed"}), 404, {'Content-Type': 'application/json'} - #except DesktopDeleteTimeout: - # log.error("Desktop delete "+desktop_id+", desktop delete timeout") - # return json.dumps({"code":4,"msg":"Desktop delete deleting timeout"}), 404, {'Content-Type': 'application/json'} +from .decorators import has_token, is_admin, ownsUserId, ownsCategoryId, ownsDomainId, allowedTemplateId -@app.route('/api/v2/desktop/start/', methods=['GET']) -def api_v2_desktop_start(desktop_id=False): +@app.route('/api/v3/desktop/start/', methods=['GET']) +@has_token +def api_v3_desktop_start(payload,desktop_id=False): if desktop_id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + if not ownsDomainId(payload,desktop_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} try: user_id=desktops.UserDesktop(desktop_id) except UserNotFound: @@ -172,27 +82,25 @@ def api_v2_desktop_start(desktop_id=False): try: now=time.time() desktop_id = desktops.Start(desktop_id) - carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} except DesktopActionTimeout: log.error("Desktop "+desktop_id+" for user "+user_id+" start timeout.") - carbon.send({'create_and_start_time':'100'}) return json.dumps({"code":2,"msg":"DesktopStart start timeout"}), 408, {'Content-Type': 'application/json'} except DesktopActionFailed: log.error("Desktop "+desktop_id+" for user "+user_id+" start failed.") - carbon.send({'create_and_start_time':'100'}) return json.dumps({"code":3,"msg":"DesktopStart start failed"}), 500, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() return json.dumps({"code":9,"msg":"DesktopStart general exception: " + error }), 401, {'Content-Type': 'application/json'} - -@app.route('/api/v2/desktop/stop/', methods=['GET']) -def api_v2_desktop_stop(desktop_id=False): +@app.route('/api/v3/desktop/stop/', methods=['GET']) +@has_token +def api_v3_desktop_stop(payload,desktop_id=False): if desktop_id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + if not ownsDomainId(payload,desktop_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} try: user_id=desktops.UserDesktop(desktop_id) except UserNotFound: @@ -214,3 +122,69 @@ def api_v2_desktop_stop(desktop_id=False): except Exception as e: error = traceback.format_exc() return json.dumps({"code":9,"msg":"DesktopStop general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/persistent_desktop', methods=['POST']) +@has_token +def api_v3_persistent_desktop_new(payload): + try: + desktop_name = request.form.get('desktop_name', type = str) + template_id = request.form.get('template_id', False) + user_id=payload['user_id'] + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + e }), 401, {'Content-Type': 'application/json'} + + if desktop_name == None or not template_id: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + if not allowedTemplateId(payload,template_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + try: + quotas.DesktopCreate(user_id) + except QuotaUserNewDesktopExceeded: + log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewDesktopExceeded: + log.error("Quota for user "+user_id+" group for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryNewDesktopExceeded: + log.error("Quota for user "+user_id+" category for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"PersistentDesktopNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} + + try: + now=time.time() + #desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,xml_id=xml_id, disk_size=disk_size) + + desktop_id = desktops.NewFromTemplate(desktop_name=desktop_name, + template_id=template_id, + payload=payload) + return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("Desktop for user "+user_id+" from template "+template_id+", user not found") + return json.dumps({"code":1,"msg":"PersistentDesktopNew user not found"}), 404, {'Content-Type': 'application/json'} + except TemplateNotFound: + log.error("Desktop for user "+user_id+" from template "+template_id+" template not found.") + return json.dumps({"code":2,"msg":"PersistentDesktopNew template not found"}), 404, {'Content-Type': 'application/json'} + except DesktopExists: + log.error("Desktop "+desktop_name+" for user "+user_id+" already exists") + return json.dumps({"code":3,"msg":"PersistentDesktopNew desktop already exists"}), 404, {'Content-Type': 'application/json'} + except DesktopNotCreated: + log.error("Desktop for user "+user_id+" from template "+template_id+" creation failed.") + return json.dumps({"code":4,"msg":"PersistentDesktopNew not created"}), 404, {'Content-Type': 'application/json'} + ### Needs more! + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"PersistentDesktopNew general exception: " + error }), 401, {'Content-Type': 'application/json'} + + + #except DesktopActionTimeout: + # log.error("Desktop delete "+desktop_id+", desktop stop timeout") + # return json.dumps({"code":2,"msg":"Desktop delete stopping timeout"}), 404, {'Content-Type': 'application/json'} + #except DesktopActionFailed: + # log.error("Desktop delete "+desktop_id+", desktop stop failed") + # return json.dumps({"code":3,"msg":"Desktop delete stopping failed"}), 404, {'Content-Type': 'application/json'} + #except DesktopDeleteTimeout: + # log.error("Desktop delete "+desktop_id+", desktop delete timeout") + # return json.dumps({"code":4,"msg":"Desktop delete deleting timeout"}), 404, {'Content-Type': 'application/json'} diff --git a/api/src/api/views/DownloadsView.py b/api/src/api/views/DownloadsView.py new file mode 100644 index 000000000..8f420beb2 --- /dev/null +++ b/api/src/api/views/DownloadsView.py @@ -0,0 +1,45 @@ +# Copyright 2017 the Isard-vdi project +# License: AGPLv3 + +#!flask/bin/python3 +# coding=utf-8 + +from api import app +from ..libv2.log import log +import traceback + +from uuid import uuid4 +import time,json +import sys,os +from flask import request, jsonify +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +from ..libv2.quotas import Quotas +quotas = Quotas() + +from ..libv2.api_users import ApiUsers, check_category_domain +users = ApiUsers() + +from ..libv2.api_downloads import Downloads + + +from .decorators import is_admin_user + +''' +ADMIN/MANAGER jwt endpoints +''' +@app.route('/api/v3/admin/downloads/desktops', methods=['GET']) +@is_admin_user +def api_v3_admin_downloads_desktops(payload): + downloads = Downloads() + return json.dumps(downloads.getNewKind('domains',payload['user_id'])), 200, {'Content-Type': 'application/json'} + +@app.route('/api/v3/admin/downloads/desktop/', methods=['POST']) +@is_admin_user +def api_v3_admin_downloads_desktops_download(desktop_id,payload): + downloads = Downloads() + res = downloads.download_desktop(desktop_id,payload['user_id']) + if not res: + json.dumps({'code':8,'description':'Could not download desktop'}), 401, {'Content-Type': 'application/json'} + return json.dumps({}), 200, {'Content-Type': 'application/json'} diff --git a/api/src/api/views/HypervisorsView.py b/api/src/api/views/HypervisorsView.py new file mode 100644 index 000000000..2e108ca21 --- /dev/null +++ b/api/src/api/views/HypervisorsView.py @@ -0,0 +1,51 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log +import traceback + +from uuid import uuid4 +import time,json +import sys,os +from flask import request +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + + +from ..libv2.quotas import Quotas +quotas = Quotas() + +from ..libv2.api_sundry import ApiSundry +api_sundry = ApiSundry() + +from .decorators import has_token, is_admin + +@app.route('/api/v3/guest_addr', methods=['POST']) +@is_admin +def api_v3_guest_addr(payload): + try: + domain_id = request.form.get('id', type = str) + ip = request.form.get('ip', type = str) + mac = request.form.get('mac', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + + if domain_id == None or ip == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + api_sundry.UpdateGuestAddr(domain_id,{'viewer':{'guest_ip':ip}}) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except UpdateFailed: + log.error("Desktop for user "+user_id+" from template "+template_id+", user not found") + return json.dumps({"code":1,"msg":"DesktopNew user not found"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + log.error("GuestAddr general exception" + error) + return json.dumps({"code":9,"msg":"GuestAddr general exception: " + error }), 401, {'Content-Type': 'application/json'} diff --git a/api/src/api/views/JumperViewerView.py b/api/src/api/views/JumperViewerView.py index bbaf274a6..1a2d6c7eb 100644 --- a/api/src/api/views/JumperViewerView.py +++ b/api/src/api/views/JumperViewerView.py @@ -17,11 +17,6 @@ from ..libv2.quotas_exc import * from flask import render_template, Response, request, redirect, url_for, send_file, send_from_directory -#from ..libv2.telegram import tsend -def tsend(txt): - None -from ..libv2.carbon import Carbon -carbon = Carbon() from ..libv2.quotas import Quotas quotas = Quotas() @@ -30,11 +25,11 @@ def tsend(txt): common = ApiDesktopsCommon() @app.route('/vw/img/', methods=['GET']) -def api_v2_img(img): +def api_v3_img(img): return send_from_directory('templates/',img) @app.route('/vw/', methods=['GET']) -def api_v2_viewer(token): +def api_v3_viewer(token): try: viewers=common.DesktopViewerFromToken(token) protocol = request.args.get('protocol', default = False) @@ -50,7 +45,6 @@ def api_v2_viewer(token): #return json.dumps({"code":2,"msg":"Jumper viewer desktop is not started"}), 404, {'Content-Type': 'application/json'} except DesktopActionTimeout: log.error("Jumper viewer desktop start timeout.") - carbon.send({'create_and_start_time':'100'}) return render_template('error.html', error='Desktop start timed out. Try again in a while...') #return json.dumps({"code":2,"msg":"Jumper viewer start timeout"}), 404, {'Content-Type': 'application/json'} except Exception as e: diff --git a/api/src/api/views/PublicView.py b/api/src/api/views/PublicView.py new file mode 100644 index 000000000..d58bc4979 --- /dev/null +++ b/api/src/api/views/PublicView.py @@ -0,0 +1,90 @@ +# Copyright 2017 the Isard-vdi project +# License: AGPLv3 + +#!flask/bin/python3 +# coding=utf-8 + +from api import app +from ..libv2.log import log +import traceback + +from uuid import uuid4 +import time,json +import sys,os +from flask import request, jsonify +from ..libv2.apiv2_exc import * + +from ..libv2.api_users import ApiUsers, check_category_domain +users = ApiUsers() + +@app.route('/api/v3', methods=['GET']) +def api_v3_test(): + return json.dumps({"name":"IsardVDI","api_version": 3}), 200, {'Content-Type': 'application/json'} + +@app.route('/api/v3/login', methods=['POST']) +@app.route('/api/v3/login/', methods=['POST']) +@app.route('/api/v3/login/', methods=['POST']) +def api_v3_login(category_id='default'): + try: + id = request.form.get('usr', type = str) + passwd = request.form.get('pwd', type = str) + + provider = request.args.get('provider', default = 'local', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + e }), 401, {'Content-Type': 'application/json'} + if id == None or passwd == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + id=provider+'-'+category_id+'-'+id+'-'+id + id_,jwt = users.Login(id, passwd, provider=provider, category_id=category_id) + return jsonify(success=True, id=id_, jwt=jwt) + except UserLoginFailed: + log.error("User "+id+" login failed.") + return json.dumps({"code":1,"msg":"User login failed"}), 403, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserExists general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/category/', methods=['GET']) +def api_v3_category(id): + try: + data = users.CategoryGet(id) + if data.get('frontend',False): return json.dumps(data), 200, {'Content-Type': 'application/json'} + return json-dumps({"code":7,"msg":"Forbidden"}) + except CategoryNotFound: + return json.dumps({"code":1,"msg":"Category "+id+" not exists in database"}), 404, {'Content-Type': 'application/json'} + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"Register general exception: " + error }), 500, {'Content-Type': 'application/json'} + +@app.route('/api/v3/categories', methods=['GET']) +def api_v3_categories(): + try: + return json.dumps(users.CategoriesFrontendGet()), 200, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"CategoriesGet general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/config', methods=['GET']) +def api_v3_config(): + try: + socials=[] + if os.environ.get('BACKEND_AUTH_GITHUB_HOST', '') != '' \ + and os.environ.get('BACKEND_AUTH_GITHUB_HOST', '') != '' \ + and os.environ.get('BACKEND_AUTH_GITHUB_SECRET', '') != '': + socials.append('Github') + + if os.environ.get('AUTHENTICATION_AUTENTICATION_GOOGLE_CLIENT_ID', '') != '' \ + and os.environ.get('AUTHENTICATION_AUTHENTICATION_GOOGLE_CLIENT_SECRET', '') != '': + socials.append('Google') + + data = {'show_admin_button': os.environ['FRONTEND_SHOW_ADMIN_BTN'], + 'social_logins': socials} + return json.dumps(data), 200, {'Content-Type': 'application/json'} + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"Config general exception: " + error }), 500, {'Content-Type': 'application/json'} diff --git a/api/src/api/views/TemplatesView.py b/api/src/api/views/TemplatesView.py index 224d876ff..c4114d3fb 100644 --- a/api/src/api/views/TemplatesView.py +++ b/api/src/api/views/TemplatesView.py @@ -15,12 +15,6 @@ from ..libv2.apiv2_exc import * from ..libv2.quotas_exc import * -#from ..libv2.telegram import tsend -def tsend(txt): - None -from ..libv2.carbon import Carbon -carbon = Carbon() - from ..libv2.quotas import Quotas quotas = Quotas() @@ -33,52 +27,69 @@ def tsend(txt): from ..libv2.api_templates import ApiTemplates templates = ApiTemplates() -@app.route('/api/v2/template', methods=['POST']) -def api_v2_template_new(): +from .decorators import has_token, is_admin, ownsDomainId, allowedTemplateId + +@app.route('/api/v3/template', methods=['POST']) +@has_token +def api_v3_template_new(payload): try: - name = request.form.get('name', type = str) - user_id = request.form.get('user_id', type = str) + template_name = request.form.get('template_name', type = str) desktop_id = request.form.get('desktop_id', type = str) except Exception as e: return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} - if user_id == None or name == None or desktop_id == None: + allowed_roles = request.form.getlist('allowed_roles') + allowed_roles = False if allowed_roles is None else allowed_roles + allowed_categories = request.form.getlist('allowed_categories', type = str) + allowed_categories = False if allowed_categories is None else allowed_categories + allowed_groups = request.form.getlist('allowed_groups', type = str) + allowed_groups = False if allowed_groups is None else allowed_groups + allowed_users = request.form.getlist('allowed_users', type = str) + allowed_users = False if allowed_users is None else allowed_users + + # if user_id == None or + if template_name == None or desktop_id == None: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} - try: - quotas.DesktopCreate(user_id) - except QuotaUserNewDesktopExceeded: - log.error("Quota for user "+user_id+" for creating another desktop is exceeded") - return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaGroupNewDesktopExceeded: - log.error("Quota for user "+user_id+" group for creating another desktop is exceeded") - return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaCategoryNewDesktopExceeded: - log.error("Quota for user "+user_id+" category for creating another desktop is exceeded") - return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} - except Exception as e: - exc_type, exc_obj, exc_tb = sys.exc_info() - fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] - log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) - return json.dumps({"code":9,"msg":"TemplateNew quota check general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + if not ownsDomainId(payload,desktop_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + # try: + # quotas.DesktopCreate(user_id) + # except QuotaUserNewDesktopExceeded: + # log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + # return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + # except QuotaGroupNewDesktopExceeded: + # log.error("Quota for user "+user_id+" group for creating another desktop is exceeded") + # return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + # except QuotaCategoryNewDesktopExceeded: + # log.error("Quota for user "+user_id+" category for creating another desktop is exceeded") + # return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + # except Exception as e: + # exc_type, exc_obj, exc_tb = sys.exc_info() + # fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + # log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + # return json.dumps({"code":9,"msg":"TemplateNew quota check general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} try: - now=time.time() - #desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,xml_id=xml_id, disk_size=disk_size) - template_id = templates.TemplateNew(name, user_id, desktop_id) - carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) + template_id = templates.New(template_name, + desktop_id, + allowed_roles=allowed_roles, + allowed_categories=allowed_categories, + allowed_groups=allowed_groups, + allowed_users=allowed_users) return json.dumps({'id': template_id}), 200, {'Content-Type': 'application/json'} - except UserNotFound: - log.error("Template for user "+user_id+" from desktop "+desktop_id+", user not found") - return json.dumps({"code":1,"msg":"TemplateNew user not found"}), 404, {'Content-Type': 'application/json'} - except TemplateNotFound: - log.error("Template for user "+user_id+" from desktop "+desktop_id+" template not found.") + # except UserNotFound: + # log.error("Template for user "+user_id+" from desktop "+desktop_id+", user not found") + # return json.dumps({"code":1,"msg":"TemplateNew user not found"}), 404, {'Content-Type': 'application/json'} + except DesktopNotFound: + log.error("Template from desktop "+desktop_id+" template not found.") return json.dumps({"code":2,"msg":"TemplateNew template not found"}), 404, {'Content-Type': 'application/json'} except DesktopNotCreated: - log.error("Template for user "+user_id+" from desktop "+desktop_id+" creation failed.") - carbon.send({'create_and_start_time':'100'}) + log.error("Template from desktop "+desktop_id+" creation failed.") return json.dumps({"code":1,"msg":"TemplateNew not created"}), 404, {'Content-Type': 'application/json'} + except TemplateExists: + log.error("Template from desktop "+desktop_id+" template id exists.") + return json.dumps({"code":3,"msg":"TemplateNew not created: template id exists"}), 404, {'Content-Type': 'application/json'} ### Needs more! except Exception as e: exc_type, exc_obj, exc_tb = sys.exc_info() @@ -86,17 +97,43 @@ def api_v2_template_new(): log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) return json.dumps({"code":9,"msg":"TemplateNew general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/template/', methods=['GET']) -def api_v2_template(id=False): +@app.route('/api/v3/template/', methods=['GET']) +@has_token +def api_v3_template(payload,template_id=False): if id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + if not allowedTemplateId(payload,template_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} try: - template = templates.Get(id) + template = templates.Get(template_id) if template: return json.dumps(template), 200, {'Content-Type': 'application/json'} return json.dumps({"code":2,"msg":"Template not found"}), 401, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() return json.dumps({"code":9,"msg":"Template general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v3/template/', methods=['DELETE']) +@has_token +def api_v3_template_delete(payload,template_id=False): + if template_id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + if not ownsDomainId(payload,template_id): return json.dumps({"code":10,"msg":"Forbidden: "}), 403, {'Content-Type': 'application/json'} + try: + templates.Delete(template_id) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except DesktopNotFound: + log.error("Template delete "+template_id+", template not found") + return json.dumps({"code":1,"msg":"Template delete id not found"}), 404, {'Content-Type': 'application/json'} + except DesktopActionFailed: + log.error("Template delete "+template_id+", template delete failed") + return json.dumps({"code":5,"msg":"Template delete deleting failed"}), 404, {'Content-Type': 'application/json'} + except DesktopActionTimeout: + log.error("Template delete "+template_id+", template delete timeout") + return json.dumps({"code":6,"msg":"Template delete deleting timeout"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"TemplateDelete general exception: " + error }), 401, {'Content-Type': 'application/json'} diff --git a/api/src/api/views/UsersView.py b/api/src/api/views/UsersView.py index 582d38be8..822c6ccf7 100644 --- a/api/src/api/views/UsersView.py +++ b/api/src/api/views/UsersView.py @@ -16,12 +16,6 @@ from ..libv2.apiv2_exc import * from ..libv2.quotas_exc import * -#from ..libv2.telegram import tsend -def tsend(txt): - None -from ..libv2.carbon import Carbon -carbon = Carbon() - from ..libv2.quotas import Quotas quotas = Quotas() @@ -31,48 +25,36 @@ def tsend(txt): from ..libv2.isardVpn import isardVpn vpn = isardVpn() -@app.route('/api/v2', methods=['GET']) -def api_v2_test(): - return "IsardVDI api v2", 200, {'Content-Type': 'application/json'} +from .decorators import has_token, is_admin, ownsUserId, ownsCategoryId, is_register -@app.route('/api/v2/login', methods=['POST']) -def api_v2_login(): - try: - id = request.form.get('id', type = str) - passwd = request.form.get('passwd', type = str) - except Exception as e: - return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} - if id == None or passwd == None: - log.error("Incorrect access parameters. Check your query.") - return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} +''' +Users jwt endpoints +''' +@app.route('/api/v3/jwt', methods=['GET']) +@has_token +def api_v3_jwt(payload): + ### Refreshes it's own token with new one. + return users.Jwt(payload['user_id']) +@app.route('/api/v3/user', methods=['GET']) +@has_token +def api_v3_user_exists(payload): try: - id_ = users.Login(id, passwd) - return jsonify(success=True, id=id_) - except UserLoginFailed: - log.error("User "+id+" login failed.") - return json.dumps({"code":1,"msg":"User login failed"}), 403, {'Content-Type': 'application/json'} + user=users.Exists(payload['user_id']) + return json.dumps(user), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"User not exists in database"}), 404, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() return json.dumps({"code":9,"msg":"UserExists general exception: " + error }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/category/', methods=['GET']) -def api_v2_category(id): - try: - data = users.CategoryGet(id) - return json.dumps(data), 200, {'Content-Type': 'application/json'} - except CategoryNotFound: - return json.dumps({"code":1,"msg":"Category "+id+" not exists in database"}), 404, {'Content-Type': 'application/json'} - - except Exception as e: - error = traceback.format_exc() - return json.dumps({"code":9,"msg":"Register general exception: " + error }), 500, {'Content-Type': 'application/json'} - -@app.route('/api/v2/register', methods=['POST']) -def api_v2_register(): +@app.route('/api/v3/user/register', methods=['POST']) +@is_register +def api_v3_user_register(payload): try: code = request.form.get('code', type = str) - domain = request.form.get("email").split("@")[-1] + # domain = request.form.get("email").split("@")[-1] except Exception as e: return ( json.dumps({"code": 8, "msg": "Incorrect access. exception: " + e}), @@ -82,15 +64,7 @@ def api_v2_register(): try: data = users.CodeSearch(code) - if check_category_domain(data.get("category"), domain): - return json.dumps(data), 200, {"Content-Type": "application/json"} - else: - log.info(f"Domain {domain} not allowed for category {data.get('category')}") - return ( - json.dumps({"code": 10, "msg": f"User domain {domain} not allowed"}), - 403, - {"Content-Type": "application/json"}, - ) + check_category_domain(data.get("category"), payload['category_id']) except CodeNotFound: log.error("Code not in database.") return json.dumps({"code":1,"msg":"Code "+code+" not exists in database"}), 404, {'Content-Type': 'application/json'} @@ -98,29 +72,65 @@ def api_v2_register(): error = traceback.format_exc() return json.dumps({"code":9,"msg":"Register general exception: " + error }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/user/', methods=['GET']) -def api_v2_user_exists(id=False): - if id == False: - log.error("Incorrect access parameters. Check your query.") - return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} - try: - user=users.Exists(id) - return json.dumps(user), 200, {'Content-Type': 'application/json'} - except UserNotFound: - log.error("User "+id+" not in database.") - return json.dumps({"code":1,"msg":"User not exists in database"}), 404, {'Content-Type': 'application/json'} + user_id=users.Create( payload['provider'], \ + payload['category_id'], \ + payload['user_id'], \ + payload['username'], \ + payload['name'], \ + data.get("role"), \ + data.get("group"), \ + photo=payload['photo'], \ + email=payload['email']) + return json.dumps({'id':user_id}), 200, {'Content-Type': 'application/json'} + except UserExists: + return json.dumps(payload), 200, {'Content-Type': 'application/json'} + except RoleNotFound: + log.error("Role "+data.get("role")+" not found.") + return json.dumps({"code":2,"msg":"Role not found"}), 404, {'Content-Type': 'application/json'} + except CategoryNotFound: + log.error("Category "+payload['category_id']+" not found.") + return json.dumps({"code":3,"msg":"Category not found"}), 404, {'Content-Type': 'application/json'} + except GroupNotFound: + log.error("Group "+data.get("group")+" not found.") + return json.dumps({"code":4,"msg":"Group not found"}), 404, {'Content-Type': 'application/json'} + except NewUserNotInserted: + log.error("User "+payload['username']+" could not be inserted into database.") + return json.dumps({"code":5,"msg":"User could not be inserted into database. Already exists!"}), 404, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() - return json.dumps({"code":9,"msg":"UserExists general exception: " + error }), 401, {'Content-Type': 'application/json'} + return json.dumps({"code":9,"msg":"UserUpdate general exception: " + error }), 401, {'Content-Type': 'application/json'} -# Update user name -@app.route('/api/v2/user/', methods=['PUT']) -def api_v2_user_update(id=False): - if id == False: + + +# Check from isard-guac if the user owns the ip +@app.route('/api/v3/user/owns_desktop', methods=['GET']) +@has_token +def api_v3_user_owns_desktop(payload): + try: + ip = request.form.get("ip", False) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + if ip == False: log.error("Incorrect access parameters. Check your query.") - return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query. At least one parameter should be specified." }), 401, {'Content-Type': 'application/json'} + try: + users.OwnsDesktop(payload['user_id'],ip) + + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except DesktopNotFound: # If not owns + log.error("User "+payload['username']+" not owns the desktop ip.") + return json.dumps({"code":1,"msg":"User "+payload['username']+" not owns the desktop ip"}), 401, {'Content-Type': 'application/json'} + except: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"OwnsDesktop general exception: " + error }), 500, {'Content-Type': 'application/json'} + +# Update user name +@app.route('/api/v3/user', methods=['PUT']) +@has_token +def api_v3_user_update(payload): try: name = request.form.get("name", "") email = request.form.get("email", "") @@ -132,7 +142,7 @@ def api_v2_user_update(id=False): log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query. At least one parameter should be specified." }), 401, {'Content-Type': 'application/json'} try: - users.Update(id,user_name=name,user_email=email,user_photo=photo) + users.Update(payload['user_id'],user_name=name,user_email=email,user_photo=photo) return json.dumps({}), 200, {'Content-Type': 'application/json'} except UpdateFailed: log.error("User "+id+" update failed.") @@ -141,291 +151,100 @@ def api_v2_user_update(id=False): error = traceback.format_exc() return json.dumps({"code":9,"msg":"UserUpdate general exception: " + error }), 401, {'Content-Type': 'application/json'} -# Add user -@app.route('/api/v2/user', methods=['POST']) -def api_v2_user_insert(): - try: - # Required - provider = request.form.get('provider', type = str) - user_uid = request.form.get('user_uid', type = str) - user_username = request.form.get('user_username', type = str) - role_id = request.form.get('role', type = str) - category_id = request.form.get('category', type = str) - group_id = request.form.get('group', type = str) - - # Optional - name=request.form.get('name', user_username, type = str) - password = request.form.get('password', False, type = str) - encrypted_password = request.form.get('encrypted_password', False, type = str) - photo = request.form.get('photo', '', type = str) - email = request.form.get('email', '', type = str) - except Exception as e: - return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} - if provider == None or user_username == None or role_id == None or category_id == None or group_id == None: - log.error("Incorrect access parameters. Check your query.") - return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} - if password == None: password = False - - try: - quotas.UserCreate(category_id,group_id) - except QuotaCategoryNewUserExceeded: - log.error("Quota for creating another user in category "+category_id+" is exceeded") - return json.dumps({"code":11,"msg":"UserNew category quota for adding user exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaGroupNewUserExceeded: - log.error("Quota for creating another user in group "+group_id+" is exceeded") - return json.dumps({"code":11,"msg":"UserNew group quota for adding user exceeded"}), 507, {'Content-Type': 'application/json'} - except Exception as e: - error = traceback.format_exc() - return json.dumps({"code":9,"msg":"UserNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} - - +@app.route('/api/v3/user', methods=['DELETE']) +@has_token +def api_v3_user_delete(payload): try: - user_id=users.Create( provider, \ - category_id, \ - user_uid, \ - user_username, \ - name, \ - role_id, \ - group_id, \ - password, \ - encrypted_password, \ - photo, \ - email) - return json.dumps({'id':user_id}), 200, {'Content-Type': 'application/json'} - except UserExists: - user_id = provider+'-'+category_id+'-'+user_uid+'-'+user_username - return json.dumps({'id':user_id}), 200, {'Content-Type': 'application/json'} - except RoleNotFound: - log.error("Role "+role_username+" not found.") - return json.dumps({"code":2,"msg":"Role not found"}), 404, {'Content-Type': 'application/json'} - except CategoryNotFound: - log.error("Category "+category_id+" not found.") - return json.dumps({"code":3,"msg":"Category not found"}), 404, {'Content-Type': 'application/json'} - except GroupNotFound: - log.error("Group "+group_id+" not found.") - return json.dumps({"code":4,"msg":"Group not found"}), 404, {'Content-Type': 'application/json'} - except NewUserNotInserted: - log.error("User "+user_username+" could not be inserted into database.") - return json.dumps({"code":5,"msg":"User could not be inserted into database. Already exists!"}), 404, {'Content-Type': 'application/json'} - except Exception as e: - error = traceback.format_exc() - return json.dumps({"code":9,"msg":"UserUpdate general exception: " + error }), 401, {'Content-Type': 'application/json'} - -@app.route('/api/v2/user/', methods=['DELETE']) -def api_v2_user_delete(user_id): - try: - users.Delete(user_id) + users.Delete(payload['user_id']) return json.dumps({}), 200, {'Content-Type': 'application/json'} except UserNotFound: - log.error("User delete "+user_id+", user not found") + log.error("User delete "+payload['user_id']+", user not found") return json.dumps({"code":1,"msg":"User delete id not found"}), 404, {'Content-Type': 'application/json'} except UserDeleteFailed: - log.error("User delete "+user_id+", user delete failed") + log.error("User delete "+payload['user_id']+", user delete failed") return json.dumps({"code":2,"msg":"User delete failed"}), 404, {'Content-Type': 'application/json'} except DesktopDeleteFailed: - log.error("User delete for user "+user_id+", desktop delete failed") - return json.dumps({"code":5,"msg":"User delete, desktop deleting failed"}), 404, {'Content-Type': 'application/json'} + log.error("User delete for user "+payload['user_id']+", user delete failed") + return json.dumps({"code":5,"msg":"User delete, user deleting failed"}), 404, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() return json.dumps({"code":9,"msg":"UserDelete general exception: " + error }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/user//templates', methods=['GET']) -def api_v2_user_templates(id=False): +@app.route('/api/v3/user/templates', methods=['GET']) +@has_token +def api_v3_user_templates(payload): if id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} - """ try: - quotas.DesktopCreateAndStart(id) - except QuotaUserNewDesktopExceeded: - log.error("Quota for user "+id+" to create a desktop exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user desktop quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaGroupNewDesktopExceeded: - log.error("Quota for user "+id+" to create a desktop in his group limits is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew group desktop limits CREATE exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaCategoryNewDesktopExceeded: - log.error("Quota for user "+id+" to create a desktop in his category limits is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew category desktop limits CREATE exceeded"}), 507, {'Content-Type': 'application/json'} - - except QuotaUserConcurrentExceeded: - log.error("Quota for user "+id+" to start a desktop is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user quota CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaGroupConcurrentExceeded: - log.error("Quota for user "+id+" to start a desktop in his group is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaCategoryConcurrentExceeded: - log.error("Quota for user "+id+" to start a desktop is his category exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user category limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} - - except QuotaUserVcpuExceeded: - log.error("Quota for user "+id+" to allocate vCPU is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user quota vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaGroupVcpuExceeded: - log.error("Quota for user "+id+" to allocate vCPU in his group is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user group limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaCategoryVcpuExceeded: - log.error("Quota for user "+id+" to allocate vCPU in his category is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user category limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} - - except QuotaUserMemoryExceeded: - log.error("Quota for user "+id+" to allocate MEMORY is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user quota MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaGroupMemoryExceeded: - log.error("Quota for user "+id+" for creating another desktop is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user group limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} - except QuotaCategoryMemoryExceeded: - log.error("Quota for user "+id+" category for desktop MEMORY allocation is exceeded") - return json.dumps({"code":11,"msg":"DesktopNew user category limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} - - except Exception as e: - error = traceback.format_exc() - return json.dumps({"code":9,"msg":"DesktopNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} """ - try: - templates = users.Templates(id) + templates = users.Templates(payload) dropdown_templates = [{'id':t['id'],'name':t['name'],'icon':t['icon'],'image':'','description':t['description']} for t in templates] return json.dumps(dropdown_templates), 200, {'Content-Type': 'application/json'} except UserNotFound: - log.error("User "+id+" not in database.") + log.error("User "+payload['user_id']+" not in database.") return json.dumps({"code":1,"msg":"UserTemplates: User not exists in database"}), 404, {'Content-Type': 'application/json'} except UserTemplatesError: - log.error("Template list for user "+id+" failed.") + log.error("Template list for user "+payload['user_id']+" failed.") return json.dumps({"code":2,"msg":"UserTemplates: list error"}), 404, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() return json.dumps({"code":9,"msg":"UserTemplates general exception: " + error }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/user//desktops', methods=['GET']) -def api_v2_user_desktops(id=False): - if id == False: - log.error("Incorrect access parameters. Check your query.") - return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} - +@app.route('/api/v3/user/desktops', methods=['GET']) +@has_token +def api_v3_user_desktops(payload): try: - desktops = users.Desktops(id) - dropdown_desktops = [ - { - "id": d["id"], - "name": d["name"], - "state": d["status"], - "type": d["type"], - "template": d["from_template"], - "viewers": d["viewers"], - "icon": d["icon"], - "image": d["image"], - "description": d["description"], - "ip": d.get("ip"), - } - for d in desktops - ] - return json.dumps(dropdown_desktops), 200, {'Content-Type': 'application/json'} + desktops = users.Desktops(payload['user_id']) + return json.dumps(desktops), 200, {'Content-Type': 'application/json'} except UserNotFound: - log.error("User "+id+" not in database.") + log.error("User "+payload['user_id']+" not in database.") return json.dumps({"code":1,"msg":"UserDesktops: User not exists in database"}), 404, {'Content-Type': 'application/json'} except UserDesktopsError: - log.error("Desktops list for user "+id+" failed.") + log.error("Desktops list for user "+payload['user_id']+" failed.") return json.dumps({"code":2,"msg":"UserDesktops: list error"}), 404, {'Content-Type': 'application/json'} except Exception as e: error = traceback.format_exc() return json.dumps({"code":9,"msg":"UserDesktops general exception: " + error }), 401, {'Content-Type': 'application/json'} - -# Add categorygroup -@app.route('/api/v2/category', methods=['POST']) -def api_v2_category_insert(): +@app.route('/api/v3/user/desktop/', methods=['GET']) +@has_token +def api_v3_user_desktop(payload,desktop_id): try: - # Required - category_name = request.form.get('category_name', type = str) - - # Optional - group_name = request.form.get('group_name', False) - category_limits = request.form.get('category_limits', False) - if category_limits == 'False': category_limits = False - if category_limits != False: category_limits=json.loads(category_limits) - category_quota = request.form.get('category_quota', False) - if category_quota == 'False': category_quota = False - if category_quota != False: category_quota=json.loads(category_quota) - group_quota = request.form.get('group_quota', False) - if group_quota == 'False': group_quota = False - if group_quota != False: group_quota=json.loads(group_quota) - - ## We should check here if limits and quotas have a correct dict schema - - ## - except Exception as e: - error = traceback.format_exc() - return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} - if category_name == None: - log.error("Incorrect access parameters. Check your query.") - return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} - - try: - category_id=users.CategoryCreate( category_name, \ - group_name, - category_limits=category_limits, - category_quota=category_quota, - group_quota=group_quota) - return json.dumps({'id':category_id}), 200, {'Content-Type': 'application/json'} - except Exception as e: - log.error("Category create error.") - error = traceback.format_exc() - return json.dumps({"code":9,"msg":"General exception when creating category pair: "+error}), 401, {'Content-Type': 'application/json'} - -# Add group -@app.route('/api/v2/group', methods=['POST']) -def api_v2_group_insert(): - try: - # Required - category_id = request.form.get('category_id', type = str) - group_name = request.form.get('group_name', type = str) - - # Optional - category_limits = request.form.get('category_limits', False) - if category_limits == 'False': category_limits = False - if category_limits != False: category_limits=json.loads(category_limits) - category_quota = request.form.get('category_quota', False) - if category_quota == 'False': category_quota = False - if category_quota != False: category_quota=json.loads(category_quota) - group_quota = request.form.get('group_quota', False) - if group_quota == 'False': group_quota = False - if group_quota != False: group_quota=json.loads(group_quota) - - ## We should check here if limits and quotas have a correct dict schema - - ## - except Exception as e: - error = traceback.format_exc() - return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} - if category_id == None: - log.error("Incorrect access parameters. Check your query.") - return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} - - try: - group_id=users.GroupCreate( category_id, \ - group_name, - category_limits=category_limits, - category_quota=category_quota, - group_quota=group_quota) - return json.dumps({'id':group_id}), 200, {'Content-Type': 'application/json'} + desktop = users.Desktop(desktop_id, payload['user_id']) + desktop_dict = { + "id": desktop["id"], + "name": desktop["name"], + "state": desktop["status"], + "type": desktop["type"], + "template": desktop["from_template"], + "viewers": desktop["viewers"], + "icon": desktop["icon"], + "image": desktop["image"], + "description": desktop["description"], + "ip": desktop.get("ip"), + } + return json.dumps(desktop_dict), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+payload['user_id']+" not in database.") + return json.dumps({"code":1,"msg":"UserDesktops: User not exists in database"}), 404, {'Content-Type': 'application/json'} + except UserDesktopsError: + log.error("Desktops get for user "+payload['user_id']+" failed.") + return json.dumps({"code":2,"msg":"UserDesktops: list error"}), 404, {'Content-Type': 'application/json'} + except DesktopNotFound: + log.error("Desktops get for user "+payload['user_id']+" not found.") + return json.dumps({"code":3,"msg":"UserDesktops: not found"}), 404, {'Content-Type': 'application/json'} except Exception as e: - log.error(" Group create error.") error = traceback.format_exc() - return json.dumps({"code":9,"msg":"General exception when creating group: "+error}), 401, {'Content-Type': 'application/json'} - + return json.dumps({"code":9,"msg":"UserDesktops general exception: " + error }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/categories', methods=['GET']) -def api_v2_categories(): - try: - return json.dumps(users.CategoriesGet()), 200, {'Content-Type': 'application/json'} - except Exception as e: - error = traceback.format_exc() - return json.dumps({"code":9,"msg":"CategoriesGet general exception: " + error }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/user//vpn//', methods=['GET']) -@app.route('/api/v2/user//vpn/', methods=['GET']) +@app.route('/api/v3/user/vpn//', methods=['GET']) +@app.route('/api/v3/user/vpn/', methods=['GET']) # kind = config,install # os = -def api_v2_user_vpn(id, kind, os=False): +@has_token +def api_v3_user_vpn(payload, kind, os=False): if not os and kind != "config": return ( json.dumps({"code": 9, "msg": "UserVpn: no OS supplied"}), @@ -433,7 +252,7 @@ def api_v2_user_vpn(id, kind, os=False): {"Content-Type": "application/json"}, ) - vpn_data = vpn.vpn_data("users", kind, os, id) + vpn_data = vpn.vpn_data("users", kind, os, payload['user_id']) if vpn_data: return json.dumps(vpn_data), 200, {"Content-Type": "application/json"} diff --git a/api/src/api/views/XmlView.py b/api/src/api/views/XmlView.py index d218f5ed2..474787307 100644 --- a/api/src/api/views/XmlView.py +++ b/api/src/api/views/XmlView.py @@ -15,15 +15,6 @@ from ..libv2.apiv2_exc import * from ..libv2.quotas_exc import * - -def tsend(txt): - None - - -from ..libv2.carbon import Carbon - -carbon = Carbon() - from ..libv2.quotas import Quotas quotas = Quotas() @@ -33,8 +24,8 @@ def tsend(txt): xml = ApiXml() -@app.route("/api/v2/xml/virt_install/", methods=["GET"]) -def api_v2_xml_virt_install(id): +@app.route("/api/v3/xml/virt_install/", methods=["GET"]) +def api_v3_xml_virt_install(id): try: data = xml.VirtInstallGet(id) return json.dumps(data), 200, {"Content-Type": "application/json"} diff --git a/api/src/api/views/__ApiViews.py b/api/src/api/views/__ApiViews.py index f22cc4e09..a19d69e2f 100644 --- a/api/src/api/views/__ApiViews.py +++ b/api/src/api/views/__ApiViews.py @@ -15,17 +15,11 @@ from ..libv2.apiv2_exc import * from ..libv2.quotas_exc import * -#from ..libv2.telegram import tsend -def tsend(txt): - None -from ..libv2.carbon import Carbon -carbon = Carbon() - from ..libv2.quotas import Quotas quotas = Quotas() -@app.route('/api/v2/category/', methods=['GET']) -def api_v2_category(id): +@app.route('/api/v3/category/', methods=['GET']) +def api_v3_category(id): try: data = app.lib.CategoryGet(id) return json.dumps(data), 200, {'Content-Type': 'application/json'} @@ -38,8 +32,8 @@ def api_v2_category(id): log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) return json.dumps({"code":9,"msg":"Register general exception: " + str(e) }), 500, {'Content-Type': 'application/json'} -@app.route('/api/v2/register', methods=['POST']) -def api_v2_register(): +@app.route('/api/v3/register', methods=['POST']) +def api_v3_register(): try: code = request.form.get('code', type = str) except Exception as e: @@ -58,8 +52,8 @@ def api_v2_register(): return json.dumps({"code":9,"msg":"Register general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/user/', methods=['GET']) -def api_v2_user_exists(id=False): +@app.route('/api/v3/user/', methods=['GET']) +def api_v3_user_exists(id=False): if id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} @@ -77,8 +71,8 @@ def api_v2_user_exists(id=False): return json.dumps({"code":9,"msg":"UserExists general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} # Update user name -@app.route('/api/v2/user/', methods=['PUT']) -def api_v2_user_update(id=False): +@app.route('/api/v3/user/', methods=['PUT']) +def api_v3_user_update(id=False): if id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} @@ -109,8 +103,8 @@ def api_v2_user_update(id=False): return json.dumps({"code":9,"msg":"UserUpdate general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} # Add user -@app.route('/api/v2/user', methods=['POST']) -def api_v2_user_insert(): +@app.route('/api/v3/user', methods=['POST']) +def api_v3_user_insert(): try: provider = request.form.get('provider', type = str) user_uid = request.form.get('user_uid', type = str) @@ -165,8 +159,8 @@ def api_v2_user_insert(): log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) return json.dumps({"code":9,"msg":"UserUpdate general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/user/', methods=['DELETE']) -def api_v2_user_delete(user_id): +@app.route('/api/v3/user/', methods=['DELETE']) +def api_v3_user_delete(user_id): try: app.lib.UserDelete(user_id) return json.dumps({}), 200, {'Content-Type': 'application/json'} @@ -187,8 +181,8 @@ def api_v2_user_delete(user_id): -@app.route('/api/v2/user//templates', methods=['GET']) -def api_v2_user_templates(id=False): +@app.route('/api/v3/user//templates', methods=['GET']) +def api_v3_user_templates(id=False): if id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} @@ -209,8 +203,8 @@ def api_v2_user_templates(id=False): log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) return json.dumps({"code":9,"msg":"UserTemplates general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/user//desktops', methods=['GET']) -def api_v2_user_desktops(id=False): +@app.route('/api/v3/user//desktops', methods=['GET']) +def api_v3_user_desktops(id=False): if id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} @@ -232,8 +226,8 @@ def api_v2_user_desktops(id=False): return json.dumps({"code":9,"msg":"UserDesktops general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/login', methods=['POST']) -def api_v2_login(): +@app.route('/api/v3/login', methods=['POST']) +def api_v3_login(): try: id = request.form.get('id', type = str) passwd = request.form.get('passwd', type = str) @@ -256,8 +250,8 @@ def api_v2_login(): return json.dumps({"code":9,"msg":"UserExists general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/desktop', methods=['POST']) -def api_v2_desktop_new(): +@app.route('/api/v3/desktop', methods=['POST']) +def api_v3_desktop_new(): try: user_id = request.form.get('id', type = str) template = request.form.get('template', type = str) @@ -299,7 +293,6 @@ def api_v2_desktop_new(): try: now=time.time() desktop_id = app.lib.DesktopNewNonpersistent(user_id,template) - carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} except UserNotFound: log.error("Desktop for user "+user_id+" from template "+template+", user not found") @@ -309,15 +302,12 @@ def api_v2_desktop_new(): return json.dumps({"code":2,"msg":"DesktopNew template not found"}), 404, {'Content-Type': 'application/json'} except DesktopNotCreated: log.error("Desktop for user "+user_id+" from template "+template+" creation failed.") - carbon.send({'create_and_start_time':'100'}) return json.dumps({"code":1,"msg":"DesktopNew not created"}), 404, {'Content-Type': 'application/json'} except DesktopActionTimeout: log.error("Desktop for user "+user_id+" from template "+template+" start timeout.") - carbon.send({'create_and_start_time':'100'}) return json.dumps({"code":2,"msg":"DesktopNew start timeout"}), 404, {'Content-Type': 'application/json'} except DesktopActionFailed: log.error("Desktop for user "+user_id+" from template "+template+" start failed.") - carbon.send({'create_and_start_time':'100'}) return json.dumps({"code":3,"msg":"DesktopNew start failed"}), 404, {'Content-Type': 'application/json'} except Exception as e: exc_type, exc_obj, exc_tb = sys.exc_info() @@ -326,8 +316,8 @@ def api_v2_desktop_new(): return json.dumps({"code":9,"msg":"DesktopNew general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/desktop//viewer/', methods=['GET']) -def api_v2_desktop_viewer(desktop_id=False, protocol=False): +@app.route('/api/v3/desktop//viewer/', methods=['GET']) +def api_v3_desktop_viewer(desktop_id=False, protocol=False): if desktop_id == False or protocol == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} @@ -356,8 +346,8 @@ def api_v2_desktop_viewer(desktop_id=False, protocol=False): log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) return json.dumps({"code":9,"msg":"DesktopViewer general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/desktop/', methods=['DELETE']) -def api_v2_desktop_delete(desktop_id=False): +@app.route('/api/v3/desktop/', methods=['DELETE']) +def api_v3_desktop_delete(desktop_id=False): if desktop_id == False: log.error("Incorrect access parameters. Check your query.") return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} @@ -365,7 +355,6 @@ def api_v2_desktop_delete(desktop_id=False): try: now=time.time() app.lib.DesktopDelete(desktop_id) - carbon.send({'delete_time':str(round(time.time()-now,2))}) return json.dumps({}), 200, {'Content-Type': 'application/json'} except DesktopNotFound: log.error("Desktop delete "+desktop_id+", desktop not found") @@ -380,8 +369,8 @@ def api_v2_desktop_delete(desktop_id=False): return json.dumps({"code":9,"msg":"DesktopDelete general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/persistent_desktop', methods=['POST']) -def api_v2_persistent_desktop_new(): +@app.route('/api/v3/persistent_desktop', methods=['POST']) +def api_v3_persistent_desktop_new(): try: name = request.form.get('name', type = str) user_id = request.form.get('user_id', type = str) @@ -420,7 +409,6 @@ def api_v2_persistent_desktop_new(): now=time.time() #desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,xml_id=xml_id, disk_size=disk_size) desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,from_template_id=template_id, disk_size=disk_size) - carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} except UserNotFound: log.error("Desktop for user "+user_id+" from template "+template_id+", user not found") @@ -430,7 +418,6 @@ def api_v2_persistent_desktop_new(): return json.dumps({"code":2,"msg":"PersistentDesktopNew template not found"}), 404, {'Content-Type': 'application/json'} except DesktopNotCreated: log.error("Desktop for user "+user_id+" from template "+template_id+" creation failed.") - carbon.send({'create_and_start_time':'100'}) return json.dumps({"code":1,"msg":"PersistentDesktopNew not created"}), 404, {'Content-Type': 'application/json'} ### Needs more! except Exception as e: @@ -439,8 +426,8 @@ def api_v2_persistent_desktop_new(): log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) return json.dumps({"code":9,"msg":"PersistentDesktopNew general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} -@app.route('/api/v2/template', methods=['POST']) -def api_v2_template_new(): +@app.route('/api/v3/template', methods=['POST']) +def api_v3_template_new(): try: name = request.form.get('name', type = str) user_id = request.form.get('user_id', type = str) @@ -473,7 +460,6 @@ def api_v2_template_new(): now=time.time() #desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,xml_id=xml_id, disk_size=disk_size) template_id = app.lib.TemplateNew(name, user_id, desktop_id) - carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) return json.dumps({'id': template_id}), 200, {'Content-Type': 'application/json'} except UserNotFound: log.error("Template for user "+user_id+" from desktop "+desktop_id+", user not found") @@ -483,7 +469,6 @@ def api_v2_template_new(): return json.dumps({"code":2,"msg":"TemplateNew template not found"}), 404, {'Content-Type': 'application/json'} except DesktopNotCreated: log.error("Template for user "+user_id+" from desktop "+desktop_id+" creation failed.") - carbon.send({'create_and_start_time':'100'}) return json.dumps({"code":1,"msg":"TemplateNew not created"}), 404, {'Content-Type': 'application/json'} ### Needs more! except Exception as e: diff --git a/api/src/api/views/decorators.py b/api/src/api/views/decorators.py new file mode 100644 index 000000000..1afb00a3a --- /dev/null +++ b/api/src/api/views/decorators.py @@ -0,0 +1,147 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +from functools import wraps +from api import app +import json, os +from flask import request +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +from flask import Flask, request, jsonify, _request_ctx_stack +# from flask_cors import cross_origin +from jose import jwt + +import logging +import traceback + +from ..libv2.flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from ..auth.tokens import get_header_jwt_payload, AuthError + +from ..libv2.apiv2_exc import TemplateNotFound, DesktopNotFound + +# from ..libv3.api_users import filter_user_templates + +def has_token(f): + @wraps(f) + def decorated(*args, **kwargs): + payload = get_header_jwt_payload() + kwargs['payload']=payload + return f(*args, **kwargs) + raise AuthError({"code": "not_allowed", + "description": + "Not enough rights" + " token."}, 401) + return decorated + +def is_register(f): + @wraps(f) + def decorated(*args, **kwargs): + payload = get_header_jwt_payload() + if payload.get('type','') == 'register': + kwargs['payload']=payload + return f(*args, **kwargs) + raise AuthError({"code": "not_allowed", + "description": + "Not register" + " token."}, 401) + return decorated +def is_admin(f): + @wraps(f) + def decorated(*args, **kwargs): + payload = get_header_jwt_payload() + if payload['role_id'] == 'admin': + kwargs['payload']=payload + return f(*args, **kwargs) + raise AuthError({"code": "not_allowed", + "description": + "Not enough rights" + " token."}, 401) + return decorated + +def is_admin_user(f): + @wraps(f) + def decorated(*args, **kwargs): + payload = get_header_jwt_payload() + if payload['role_id'] == 'admin': + kwargs['payload']=payload + return f(*args, **kwargs) + raise AuthError({"code": "not_allowed", + "description": + "Not enough rights" + " token."}, 401) + return decorated + + +### Helpers +def ownsUserId(payload,user_id): + if payload['role_id'] == 'admin': return True + if payload['role_id'] == 'manager' and user_id.split['-'][1]==payload['category_id']: return True + if payload['user_id'] == user_id: return True + return False + +def ownsCategoryId(payload,category_id): + if payload['role_id'] == 'admin': return True + if payload['role_id'] == 'manager' and category_id==payload['category_id']: return True + return False + +def ownsDomainId(payload,desktop_id): + if payload['role_id'] == 'admin': return True + if payload['role_id'] == 'manager' and payload['category_id'] == desktop_id.split('-')[1]: return True + if payload['role_id'] == 'advanced': + with app.app_context(): + if str(r.table('domains').get(desktop_id).pluck('tag').run(db.conn).get('tag',False)).startswith('_'+payload['user_id']): + return True + if desktop_id.startswith('_'+payload['user_id']): return True + return False + +# def allowedTemplateId(payload,template_id): +# if payload['role_id'] == 'admin': return True +# allowed=r.table('domains').get(template_id).pluck('allowed').run(db.conn) +# if payload['role_id'] == 'manager' and payload['category_id'] == template_id.split('-')[1]: return True +# if payload['role_id'] == 'advanced': +# with app.app_context(): +# if str(r.table('domains').get(template_id).pluck('tag').run(db.conn).get('tag',False)).startswith('_'+payload['user_id']): +# return True +# if template_id.startswith('_'+payload['user_id']): return True +# return False + +def allowedTemplateId(payload,template_id): + try: + template=r.table('domains').get(template_id).pluck('allowed').run(db.conn) + except: + raise AuthError({"code": 1, + "msg": + "Not found template "+template_id}, 401) + alloweds=template['allowed'] + if payload['role_id'] == 'admin': return True + if payload['role_id'] == 'manager' and payload['category_id'] == template['category']: return True + if alloweds['categories'] and payload['category_id'] in alloweds['categories']: return True + if alloweds['groups'] and payload['group_id'] in alloweds['groups']: return True + if alloweds['users'] and payload['user_id'] in alloweds['users']: return True + return False + +# def allowedId(payload,category_id, alloweds): +# if payload['role'] == 'admin': return True +# if payload['role'] == 'manager' and payload['category_id'] == category_id: return True +# if payload['category_id'] in alloweds['categories']: return True +# if payload['group_id'] in alloweds['groups']: return True +# if payload['user_id'] in alloweds['users']: return True +# return False + +# Error handler +# class PermissionError(Exception): +# def __init__(self, error, status_code): +# self.error = error +# self.status_code = status_code + +# @app.errorhandler(PermissionError) +# def handle_auth_error(ex): +# response = jsonify(ex.error) +# response.status_code = ex.status_code +# return response \ No newline at end of file diff --git a/api/src/startv3.py b/api/src/startv3.py new file mode 100644 index 000000000..d43a6ea8b --- /dev/null +++ b/api/src/startv3.py @@ -0,0 +1,18 @@ +from gevent import monkey +monkey.patch_all() + +from api import app + +from api import socketio +from api.libv2 import api_socketio_domains +from api.libv2 import api_socketio_secrets +from api.libv2 import api_socketio_deployments + +import os +debug=True if os.environ['LOG_LEVEL'] == 'DEBUG' else False + +if __name__ == '__main__': + api_socketio_domains.start_domains_thread() + api_socketio_secrets.start_secrets_thread() + api_socketio_deployments.start_deployments_thread() + socketio.run(app,host='0.0.0.0', port=5000, debug=debug, log_output=debug) diff --git a/api/src/tests/01_users_test.py b/api/src/tests/01_users_test.py new file mode 100644 index 000000000..c18832995 --- /dev/null +++ b/api/src/tests/01_users_test.py @@ -0,0 +1,428 @@ + +import time,json,os +from datetime import datetime, timedelta +from jose import jwt +import traceback +from pprint import pprint + +from rethinkdb import r + +import secrets + +domain="localhost" +verifycert=False +## End set global vars + +import unittest +import requests, responses + +# Users, so also desktops (one for each user) +items_to_create=1 +#download_desktop="slax93" +#download_desktop="zxspectrum" +download_desktop="tetros" + +class TestSimulate(unittest.TestCase): + + auths={} + dbconn=None + base="http://localhost:5000/api/v3" + + @classmethod + def setUpClass(cls): + cls.dbconn=r.connect('isard-db', 28015).repl() + admin_secret_data=r.db('isard').table('secrets').get('isardvdi').run() + admin_jwt=jwt.encode({ 'exp': datetime.utcnow() + timedelta(hours=4), + 'kid':admin_secret_data['id'], + 'data':{ + 'role_id':admin_secret_data['role_id'], + 'category_id':admin_secret_data['category_id']}}, + admin_secret_data['secret'], + algorithm='HS256') + cls.auths['isardvdi']={'secret':admin_secret_data, + 'jwt': admin_jwt, + 'header':{'Authorization': 'Bearer ' + admin_jwt}} + + manager_secret=secrets.token_urlsafe(32) + manager_secret_data={'id':'API_TESTS_CATEGORY_NAME', + 'secret':manager_secret, + 'description': 'API_TESTS_CATEGORY_DESCRIPTION', + 'domain': 'localhost', + "category_id":"API_TESTS_CATEGORY_NAME", + "role_id":"manager"} + r.db('isard').table('secrets').insert(manager_secret_data).run() + manager_jwt=jwt.encode({ 'exp': datetime.utcnow() + timedelta(hours=4), + 'kid':manager_secret_data['id'], + 'data':{ + 'role_id':manager_secret_data['role_id'], + 'category_id':manager_secret_data['category_id']}}, + manager_secret_data['secret'], + algorithm='HS256') + cls.auths['manager']={'secret':manager_secret, + 'jwt': manager_jwt, + 'header':{'Authorization': 'Bearer ' + manager_secret}} + + @classmethod + def tearDownClass(cls): + r.db('isard').table('secrets').get('API_TESTS_CATEGORY_NAME').delete().run() + cls.dbconn.close() + cls.auths = None + + def test_010_login(self): + data={'usr':'admin', + 'pwd':'IsardVDI'} + category_id='default' + provider='local' + response = requests.post(self.base+'/login/'+category_id+'?provider='+provider, + data=data, + verify=False) + # {'id':'local-default-admin-admin', 'jwt':'XXXXXXXXXXXX'} + self.assertEqual(200, response.status_code) + + + def test_020_admin_secret_correct(self): + admin_secret=r.db('isard').table('secrets').get('isardvdi').pluck('secret').run()['secret'] + self.assertEqual(admin_secret,os.environ['API_ISARDVDI_SECRET']) + # self.auths['isardvdi']={'secret':admin_secret, + # 'header':{'Authorization': 'Bearer ' + admin_secret}} + + def test_030_admin_get_jwt_self(self): + response = requests.get(self.base+'/admin/jwt/local-default-admin-admin', + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + + def test_040_admin_add_category(self): + category = {'category_name': 'API TESTS CATEGORY NAME', + 'description': 'API TESTS DESCRIPTION', + 'group_name': 'API TESTS GROUP NAME', + 'frontend':False} + response = requests.post(self.base+'/admin/category', + data=category, + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + self.assertEqual(json.loads(response.text)['id'],'API_TESTS_CATEGORY_NAME') + + def test_050_admin_get_categories(self): + response = requests.get(self.base+'/admin/categories', + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + + category=[c['id'] for c in json.loads(response.text) if c['id']=='API_TESTS_CATEGORY_NAME'] + self.assertEqual(category[0], 'API_TESTS_CATEGORY_NAME') + + def test_060_admin_get_group(self): + response = requests.get(self.base+'/admin/groups', + headers=self.auths['isardvdi']['header'], + verify=False) + + self.assertEqual(200, response.status_code) + + group=[g['id'] for g in json.loads(response.text) if g['parent_category']=='API_TESTS_CATEGORY_NAME'] + self.assertEqual(group[0], 'API_TESTS_CATEGORY_NAME-API_TESTS_GROUP_NAME') + + def test_070_admin_add_users(self): + for i in range(1,items_to_create+1): + data = {"provider":'local', + "user_uid":'API_TEST_UID_'+str(i), + "user_username":'API_TEST_USERNAME_'+str(i), + "name": 'API TEST NAME '+str(i), + "role_id":'user', + "category_id":'API_TESTS_CATEGORY_NAME', + "group_id":'API_TESTS_CATEGORY_NAME-API_TESTS_GROUP_NAME', + "password":'P@55s0rd'} + response = requests.post(self.base+'/admin/user', + data=data, + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + self.assertEqual(json.loads(response.text)['id'],'local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)) + + def test_080_admin_get_users_jwt(self): + for i in range(1,items_to_create+1): + response = requests.get(self.base+'/admin/jwt/local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i), + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + + def test_090_admin_download_desktop(self): + response = requests.get(self.base+'/admin/jwt/local-default-admin-admin', + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + jwt=json.loads(response.text)['jwt'] + + response = requests.get(self.base+'/admin/downloads/desktops', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + for dom in json.loads(response.text): + if dom['id'].endswith(download_desktop): + response = requests.post(self.base+'/admin/downloads/desktop/'+dom['id'], + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + desktop_id=dom['id'] + + loop=0 + while loop<=60: + if 'Stopped' == r.db('isard').table('domains').get(desktop_id).pluck('status').run()['status']: + self.assertTrue(True) + break + time.sleep(1) + loop+=1 + if loop>60: self.assertTrue(False) + + + def test_100_admin_add_template(self): + response = requests.get(self.base+'/admin/jwt/local-default-admin-admin', + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + jwt=json.loads(response.text)['jwt'] + + response = requests.get(self.base+'/admin/downloads/desktops', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + for dom in json.loads(response.text): + if dom['id'].endswith(download_desktop): + desktop_id=dom['id'] + + data = {'template_name': 'API TEST TEMPLATE', + 'desktop_id': desktop_id, + 'allowed_groups':['API_TESTS_CATEGORY_NAME-API_TESTS_GROUP_NAME']} + # Check if exists + response = requests.get(self.base+'/template/_local-default-admin-admin-API_TEST_TEMPLATE', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + if response.status_code != 200: + #Create + response = requests.post(self.base+'/template', + data=data, + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + self.assertEqual(json.loads(response.text)['id'],'_local-default-admin-admin-API_TEST_TEMPLATE') + + loop=0 + while loop<=15: + response = requests.get(self.base+'/template/_local-default-admin-admin-API_TEST_TEMPLATE', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + if response.status_code == 200: + self.assertTrue(True) + break + time.sleep(1) + loop+=1 + if loop>15: self.assertTrue(False) + + # def test_9_template(self): + # response = requests.get(self.base+'/admin/jwt/local-default-admin-admin', + # headers=self.auths['isardvdi']['header'], + # verify=False) + # self.assertEqual(200, response.status_code) + # jwt=json.loads(response.text)['jwt'] + + # data = {'template_name': 'template a6', + # 'desktop_id': '_local-default-admin-admin_downloaded_slax93', + # 'allowed_roles':['admin']} + # response = requests.post(self.base+'/template', + # data=data, + # headers={'Authorization': 'Bearer ' + jwt}, + # verify=False) + # self.assertEqual(200, response.status_code) + + def test_110_admin_add_desktops(self): + for i in range(1,items_to_create+1): + response = requests.get(self.base+'/admin/jwt/local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i), + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + jwt=json.loads(response.text)['jwt'] + + data={'desktop_name': 'API_TESTS_DESKTOP', + 'template_id': '_local-default-admin-admin-API_TEST_TEMPLATE'} + response = requests.post(self.base+'/persistent_desktop', + data=data, + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertTrue(200 == response.status_code or 3 == json.loads(response.text)['code']) + + stopped=0 + while stopped<=10: + response = requests.get(self.base+'/user/desktop/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + if json.loads(response.text)['state'] != 'Stopped': + stopped+=1 + time.sleep(1) + else: + self.assertTrue(True) + break + if stopped>10: self.assertTrue(False) + + def test_120_admin_start_desktops(self): + for i in range(1,items_to_create+1): + response = requests.get(self.base+'/admin/jwt/local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i), + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + jwt=json.loads(response.text)['jwt'] + + response = requests.get(self.base+'/desktop/start/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + started=0 + while started<=10: + response = requests.get(self.base+'/user/desktop/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + if json.loads(response.text)['state'] != 'Started': + started+=1 + time.sleep(1) + else: + self.assertTrue(True) + break + if started>10: self.assertTrue(False) + + def test_130_get_viewers(self): + for i in range(1,items_to_create+1): + response = requests.get(self.base+'/admin/jwt/local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i), + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + jwt=json.loads(response.text)['jwt'] + + response = requests.get(self.base+'/user/desktop/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + self.assertEqual(json.loads(response.text)['viewers'],['file-spice', 'browser-vnc']) + + response = requests.get(self.base+'/desktop/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP/viewer/browser-vnc', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + pprint(json.loads(response.text)) + + response = requests.get(self.base+'/desktop/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP/viewer/file-spice', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + pprint(json.loads(response.text)) + + def test_140_admin_stop_desktops(self): + for i in range(1,items_to_create+1): + response = requests.get(self.base+'/admin/jwt/local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i), + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + jwt=json.loads(response.text)['jwt'] + + response = requests.get(self.base+'/desktop/stop/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + stopped=0 + while stopped<=40: + response = requests.get(self.base+'/user/desktop/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + if json.loads(response.text)['state'] != 'Stopped': + stopped+=1 + time.sleep(1) + else: + self.assertTrue(True) + break + if stopped>40: self.assertTrue(False) + + def test_150_admin_delete_desktops(self): + for i in range(1,items_to_create+1): + response = requests.get(self.base+'/admin/jwt/local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i), + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + jwt=json.loads(response.text)['jwt'] + + response = requests.delete(self.base+'/desktop/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + deleted=0 + while deleted<=10: + response = requests.get(self.base+'/user/desktop/_local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i)+'-API_TESTS_DESKTOP', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + if json.loads(response.text)['code'] != 3: + deleted+=1 + time.sleep(1) + else: + self.assertTrue(True) + break + if deleted>10: self.assertTrue(False) + + response = requests.get(self.base+'/admin/jwt/local-default-admin-admin', + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + jwt=json.loads(response.text)['jwt'] + + response = requests.get(self.base+'/user/desktops', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + result = True if '_local-default-admin-admin_downloaded_'+download_desktop in [r['id'] for r in json.loads(response.text)][0] else False + self.assertTrue(result) + + response = requests.delete(self.base+'/desktop/_local-default-admin-admin_downloaded_'+download_desktop, + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + def test_160_admin_delete_users(self): + for i in range(1,items_to_create+1): + response = requests.delete(self.base+'/admin/user/local-API_TESTS_CATEGORY_NAME-API_TEST_UID_'+str(i)+'-API_TEST_USERNAME_'+str(i), + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + + def test_170_admin_delete_template(self): + response = requests.get(self.base+'/admin/jwt/local-default-admin-admin', + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + jwt=json.loads(response.text)['jwt'] + + response = requests.delete(self.base+'/template/_local-default-admin-admin-API_TEST_TEMPLATE', + headers={'Authorization': 'Bearer ' + jwt}, + verify=False) + self.assertEqual(200, response.status_code) + + def test_180_admin_delete_group(self): + response = requests.delete(self.base+'/admin/group/API_TESTS_CATEGORY_NAME-API_TESTS_GROUP_NAME', + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + + def test_190_admin_delete_category(self): + response = requests.delete(self.base+'/admin/category/API_TESTS_CATEGORY_NAME', + headers=self.auths['isardvdi']['header'], + verify=False) + self.assertEqual(200, response.status_code) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/api/srcv2/api/__init__.py b/api/srcv2/api/__init__.py new file mode 100644 index 000000000..4539b036c --- /dev/null +++ b/api/srcv2/api/__init__.py @@ -0,0 +1,55 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 + +from flask import Flask, send_from_directory, render_template + +import os + +app = Flask(__name__, static_url_path='') +app.url_map.strict_slashes = False + +''' +App secret key for encrypting cookies +You can generate one with: + import os + os.urandom(24) +And paste it here. +''' +app.secret_key = "Change this key!//\xf7\x83\xbe\x17\xfa\xa3zT\n\\]m\xa6\x8bF\xdd\r\xf7\x9e\x1d\x1f\x14'" + +print('Starting isard api...') + +from api.libv2.load_config import loadConfig +cfg=loadConfig(app) +if not cfg.init_app(app): exit(0) + +import logging as log + +''' +Debug should be removed on production! +''' +if app.debug: + log.warning('Debug mode: {}'.format(app.debug)) +else: + log.info('Debug mode: {}'.format(app.debug)) + +'''' +Import all views +''' +from .views import UsersView +from .views import DeploymentsView +from .views import CommonView +from .views import DesktopsNonPersistentView +from .views import JumperViewerView +from .views import DesktopsPersistentView +from .views import XmlView +from .views import SundryView +from .views import TemplatesView + + + diff --git a/api/srcv2/api/auth/__init__.py b/api/srcv2/api/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/srcv2/api/auth/authentication.py b/api/srcv2/api/auth/authentication.py new file mode 100644 index 000000000..16dd558be --- /dev/null +++ b/api/srcv2/api/auth/authentication.py @@ -0,0 +1,371 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!/usr/bin/env python +# coding=utf-8 +from flask_login import LoginManager, UserMixin +import time + +from api import app + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +from ..libv2.flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from ..libv2.log import * + +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = "login" + +ram_users={} + +class LocalUsers(): + def __init__(self): + None + + def getUser(self,username): + with app.app_context(): + usr=r.table('users').get(username).run(db.conn) + return usr + +class User(UserMixin): + def __init__(self, dict): + self.id = dict['id'] + self.username = dict['id'] + self.name = dict['name'] + self.password = dict['password'] + self.role = dict['role'] + self.category = dict['category'] + self.group = dict['group'] + self.path = dict['category']+'/'+dict['group']+'/'+dict['id']+'/' + self.email = dict['email'] + self.quota = dict['quota'] + self.auto = dict['auto'] if 'auto' in dict.keys() else False + self.is_admin=True if self.role=='admin' else False + self.active = dict['active'] + + def is_active(self): + return self.active + + def is_anonymous(self): + return False + +def logout_ram_user(username): + del(ram_users[username]) + +@login_manager.user_loader +def user_loader(username): + if username not in ram_users: + user=app.localuser.getUser(username) + if user == None: return + ram_users[username]=user + return User(ram_users[username]) + +''' +LOCAL AUTHENTICATION AGAINS RETHINKDB USERS TABLE +''' +try: + import ldap +except Exception as e: + log.warning('No ldap module found, disabling ldap authentication') + +from .ldapauth import myLdapAuth +class auth(object): + def __init__(self): + None + + + def fake_check(self,fakeuser,admin_password): + return self.authentication_fakeadmin(fakeuser,admin_password) + + + def _check(self,username,password): + if username=='admin': + user_validated=self.authentication_local(username,password) + if user_validated: + self.update_access(username) + return user_validated + with app.app_context(): + cfg=r.table('config').get(1).run(db.conn) + if cfg == None: + return False + ldap_auth=cfg['auth']['ldap'] + local_auth=cfg['auth']['local'] + local_user=r.table('users').get(username).run(db.conn) + if local_user != None: + if local_user['provider']=='local' and local_auth['active']: + user_validated = self.authentication_local(username,password) + if user_validated: + self.update_access(username) + return user_validated + if local_user['provider']=='ldap' and ldap_auth['active']: + user_validated = self.authentication_ldap(username,password) + if user_validated: + self.update_access(username) + return user_validated + #~ if local_user['kind']=='google_oauth2': + #~ return self.authentication_googleOauth2(username,password) + else: + if ldap_auth['active']: + user_validated=self.authentication_ldap(username,password) + if user_validated: + user=self.authentication_ldap(username,password,returnObject=False) + if r.table('categories').get(user['category']).run(db.conn) == None: + r.table('categories').insert({ 'id':user['category'], + 'name':user['category'], + 'description':'', + 'quota':r.table('roles').get(user['role']).run(db.conn)['quota']}).run(db.conn) + if r.table('groups').get(user['group']).run(db.conn) == None: + r.table('groups').insert({ 'id':user['group'], + 'name':user['group'], + 'description':'', + 'quota':r.table('categories').get(user['category']).run(db.conn)['quota']}).run(db.conn) + r.table('users').insert(user).run(db.conn) + self.update_access(username) + return User(user) + else: + return False + return False + + def authentication_local(self,username,password): + with app.app_context(): + dbuser=r.table('users').get(username).run(db.conn) + log.info('USER:'+username) + if dbuser == None or dbuser['active'] != True: + return False + pw=Password() + if pw.valid(password,dbuser['password']): + #~ TODO: Check active or not user + return User(dbuser) + else: + return False + + + def authentication_ldap(self,username,password,returnObject=True): + cfg=r.table('config').get(1).run(db.conn)['auth'] + try: + conn = ldap.initialize(cfg['ldap']['ldap_server']) + id_conn = conn.search(cfg['ldap']['bind_dn'],ldap.SCOPE_SUBTREE,"uid=%s" % username.split('-')[-1]) + tmp,info=conn.result(id_conn, 0) + user_dn=info[0][0] + if conn.simple_bind_s(who=user_dn,cred=password): + ''' + config/ldapauth.py has the function you can change to adapt to your ldap + ''' + au=myLdapAuth() + newUser=au.newUser(username,info[0]) + return User(newUser) if returnObject else newUser + else: + return False + except Exception as e: + log.error("LDAP ERROR: "+str(e)) + return False + + def authentication_fakeadmin(self,fakeuser,admin_password): + with app.app_context(): + admin_dbuser=r.table('users').get('admin').run(db.conn) + if admin_dbuser == None: + return False + pw=Password() + if pw.valid(admin_password,admin_dbuser['password']): + with app.app_context(): + dbuser=r.table('users').get(fakeuser).run(db.conn) + if dbuser == None: + return False + else: + dbuser['name']='FAKEUSER' + #~ quota = admin_dbuser['quota'] + #~ { 'domains':{ 'desktops': 99, + #~ 'templates':99, + #~ 'running': 99}, + #~ 'hardware':{'vcpus': 8, + #~ 'ram': 1000000}} # 10GB + dbuser['quota']=admin_dbuser['quota'] + dbuser['role']='admin' + ram_users[fakeuser]=dbuser + return User(dbuser) + else: + return False + + def update_access(self,username): + with app.app_context(): + r.table('users').get(username).update({'accessed':time.time()}).run(db.conn) + + def ldap_users_exists(self,commit=False): + cfg=r.table('config').get(1).run(db.conn)['auth'] + users=list(r.table('users').filter({'active':True,'provider':'ldap'}).pluck('id','name','accessed').run(db.conn)) + nonvalid=[] + valid=[] + for u in users: + conn = ldap.initialize(cfg['ldap']['ldap_server']) + id_conn = conn.search(cfg['ldap']['bind_dn'],ldap.SCOPE_SUBTREE,"uid=%s" % u['id']) + tmp,info=conn.result(id_conn, 0) + if len(info): + valid.append(u) + else: + nonvalid.append(u) + if commit: + nonvalid_list= [ u['id'] for u in nonvalid ] + return r.table('users').get_all(r.args(nonvalid_list)).update({'active':False}).run(db.conn) + else: + return {'nonvalid':nonvalid,'valid':valid} + #~ print(nonvalid) + #~ print('Non valids: '+str(len(nonvalid))) + #~ print('Valids: '+str(len(valid))) + +''' +VOUCHER AUTH +''' +import smtplib +class Email(object): + def __init__(self): + try: + self.passwd=os.environ.get('ISARDMAILKEY') + except Exception as e: + print('Environtment email password not found.') + + def send(self,to_addr_list,subject,message): + login = 'isard.vdi@gmail.com' + # In bash do: export ISARDMAILKEY=some_value + password = os.environ.get('ISARDMAILKEY') + smtpserver='smtp.gmail.com' + smtpport=587 + from_addr='isard.vdi@gmail.com' + subject=subject + message=message + header = 'From: %s\n' % from_addr + header += 'To: %s\n' % ','.join(to_addr_list) + # header += 'Cc: %s\n' % ','.join(cc_addr_list) + header += 'Subject: %s\n\n' % subject + message = header + message + + server = smtplib.SMTP(smtpserver, smtpport) # use both smtpserver and -port + server.starttls() + server.login(login,password) + problems = server.sendmail(from_addr, to_addr_list, message) + server.quit() + #~ print 'Sent email: '+error_header + + def email_validation(self,email,code): + subject= 'IsardVDI email verification' + message= 'You have requested access to IsardVDI online demo platform through this email address.\n\n'+\ + 'Please access this link to get your demo user: https://try.isardvdi.com/voucher_validation/'+code + self.send([email],subject,message) + + def account_activation(self,email,user,passwd): + subject= 'IsardVDI credentials' + message= 'Here you have your demo user and passwords: \n\n'+\ + 'Username: '+user+'\n'+\ + 'Password: '+passwd+'\n\n'+\ + 'IsardVDI: https://try.isardvdi.com' + self.send([email],subject,message) + +class auth_voucher(object): + def __init__(self): + self.pw=Password() + self.email=Email() + + def check_voucher(self,voucher): + dbv=r.table('vouchers').get(voucher).run(db.conn) + if dbv == None: return False + return True + + def check_validation(self,code): + user=list(r.table('users').filter({'code':code}).run(db.conn)) + if not len(user): return False + return True + + def check_user_exists(self,email): + user=r.table('users').get(email).run(db.conn) + if user == None: return False + return True + + def register_user(self,voucher,email,remote_addr): + user=self.user_tmpl(voucher,email,remote_addr) + if r.table('categories').get(user['category']).run(db.conn) == None: + r.table('categories').insert({ 'id':user['category'], + 'name':user['category'], + 'description':'', + 'quota':r.table('roles').get(user['role']).run(db.conn)['quota']}).run(db.conn) + if r.table('groups').get(user['group']).run(db.conn) == None: + r.table('groups').insert({ 'id':user['group'], + 'name':user['group'], + 'description':'', + 'quota':r.table('categories').get(user['category']).run(db.conn)['quota']}).run(db.conn) + r.table('users').insert(user, conflict='update').run(db.conn) + + # Send email with code=user['code'] + self.email.email_validation(email,user['code']) + return User(user) + #~ return False + + def activate_user(self,code,remote_addr): + user=list(r.table('users').filter({'code':code}).run(db.conn)) + if len(user): + user=user[0] + key=self.pw.generate_human() + r.table('users').filter({'code':code}).update({'active':True,'password':self.pw.encrypt(key)}).run(db.conn) + log=list(r.table('users').filter({'code':code}).run(db.conn))[0]['log'] + log.append({'when':time.time(),'ip':remote_addr,'action':'Activate user'}) + r.table('users').filter({'code':code}).update({'log':log}).run(db.conn) + #Send email with email=user['email'], user=user['username'], key + self.email.account_activation(user['email'], user['username'], key) + return True + return False + + def user_tmpl(self,voucher, email, remote_addr): + usr = {'id': email.replace('@','_').replace('.','_'), + 'name': email.split('@')[0], + 'provider': 'local', + 'active': False, + 'accessed': time.time(), + 'username': email.replace('@','_').replace('.','_'), + 'password': self.pw.encrypt(self.pw.generate_human()), #Unknown temporary key, updated on activate_user + 'code': self.pw.encrypt(self.pw.generate_human()).replace('/','-').replace('.','_'), # Code for email confirmation + 'log':[{'when':time.time(),'ip':remote_addr,'action':'Register user'}], + 'role': 'advanced', + 'category': voucher, + 'group': voucher, + 'email': email, + 'quota': {'domains': {'desktops': 3, + 'desktops_disk_max': 999999999, # 1TB + 'templates': 2, + 'templates_disk_max': 999999999, + 'running': 1, + 'isos': 1, + 'isos_disk_max': 999999999}, + 'hardware': {'vcpus': 2, + 'memory': 20000000}}, # 2GB + } + r.table('users').insert(usr, conflict='update').run(db.conn) + return usr + + def update_access(self,username): + with app.app_context(): + r.table('users').get(username).update({'accessed':time.time()}).run(db.conn) + +''' +PASSWORDS MANAGER +''' +import bcrypt,string,random +class Password(object): + def __init__(self): + None + + def valid(self,plain_password,enc_password): + return bcrypt.checkpw(plain_password.encode('utf-8'), enc_password.encode('utf-8')) + + def encrypt(self,plain_password): + return bcrypt.hashpw(plain_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + def generate_human(self,length=6): + chars = string.ascii_letters + string.digits + '!@#$*' + rnd = random.SystemRandom() + return ''.join(rnd.choice(chars) for i in range(length)) + diff --git a/api/srcv2/api/auth/ldapauth.py b/api/srcv2/api/auth/ldapauth.py new file mode 100644 index 000000000..6be3a8395 --- /dev/null +++ b/api/srcv2/api/auth/ldapauth.py @@ -0,0 +1,143 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +## +# WARNING: Duplicated code of webapp/webapp/webapp/config/ldapauth.py +## + +import time +from rethinkdb import RethinkDB + +from api import app +from ..libv2.flask_rethink import RDB + +r = RethinkDB() +db = RDB(app) +db.init_app(app) + +''' +Modify this class as you need for your ldap. +Be sure to return all keys included in example user dictionaries. +''' +class myLdapAuth(object): + def __init__(self): + None + + def newUser(self,id,info): + ''' + Here you have a generic_user that you can fill with your data. + You can parse ldap info data as you want to reflect it on dict. + As an example there is myuser that calls other functions to get + the correct data for each key in dict. + ''' + ''' + generic_user={ 'id':username, + 'name':username + 'kind':'ldap', + 'username':username, + 'password':None, + 'role':'user', + 'category':'generic', + 'group':'generic', + 'email':'', + 'quota':setUserQuota('user') + } + ''' + username=id.split('-')[-1] + category = self._setUserCategory(username,info) + group = self._setUserGroup(username,info) + myuser={'id': 'ldap-'+category+'-'+username+'-'+username, + 'name': username if 'displayName' not in info[1].keys() else info[1]['displayName'][0].decode('utf-8'), + 'uid': username, + 'provider': 'ldap', + 'active': True, + 'accessed': time.time(), + 'username': username, + 'password': None, + 'role': self._setUserRole(username), + 'category': category, + 'group': category+'-'+group, + 'email': info[1]['mail'][0].decode('utf-8'), + 'photo': "", + 'default_templates':[], + 'quota': self._setQuota(self._setUserRole(username), category, group), + 'group_uid': category+'-'+group + } + return myuser + + def _setUserCategory(self,username,info): + return info[1]["homeDirectory"][0].decode('utf-8').split("/home/users/")[1].split("/"+username)[0].split('/')[0] + + def _setUserGroup(self,username,info): + return info[1]["homeDirectory"][0].decode('utf-8').split("/home/users/")[1].split("/"+username)[0].split('/')[1] + + def _setUserRole(self,username): + if any(char.isdigit() for char in username): + return 'user' + else: + return 'advanced' + + ''' + Get quotas from 'roles' table based on role. This should be a dictionary. + Please connect to your rethink database and query for roles table. + ''' + def _setQuota(self,role_id,category_id,group_id): + with app.app_context(): + category = r.table('categories').get(category_id).run(db.conn) + if category == None: + category = { + "description": "" , + "id": category_id , + "limits": { + "desktops": 200 , + "desktops_disk_size": 40 , + "isos": 4 , + "isos_disk_size": 0 , + "memory": 200 , + "running": 100 , + "templates": 20 , + "templates_disk_size": 0 , + "users": 400 , + "vcpus": 200 + } , + "name": category_id , + "quota": { + "desktops": 3 , + "desktops_disk_size": 40 , + "isos": 0 , + "isos_disk_size": 0 , + "memory": 6 , + "running": 2 , + "templates": 0 , + "templates_disk_size": 0 , + "vcpus": 6 + } + } + r.table('categories').insert(category).run(db.conn) + + group = r.table('groups').get(group_id).run(db.conn) + if group == None: + group = { + "description": "" , + "id": category_id+'-'+group_id , + "limits": False , + "parent_category": category_id, + "uid": group_id, + "name": group_id, + "enrollment": {'manager':False, 'advanced':False, 'user':False}, + "quota": { + "desktops": 3 if role_id == 'user' else 6, + "desktops_disk_size": 40 , + "isos": 0 if role_id == 'user' else 2, + "isos_disk_size": 0 , + "memory": 6 if role_id == 'user' else 12, + "running": 2 if role_id == 'user' else 4, + "templates": 0 if role_id == 'user' else 4, + "templates_disk_size": 0 , + "vcpus": 6 if role_id == 'user' else 12, + } + } + r.table('groups').insert(group).run(db.conn) + return group['quota'] diff --git a/api/srcv2/api/libv2/__apiv2.py b/api/srcv2/api/libv2/__apiv2.py new file mode 100644 index 000000000..0c5f44c4b --- /dev/null +++ b/api/srcv2/api/libv2/__apiv2.py @@ -0,0 +1,749 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +#import pem +#from OpenSSL import crypto + +# ~ from contextlib import closing + +#import rethinkdb as r +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError +# ~ from ..libv1.log import * +import logging as log + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from ..auth.authentication import * + +from ..libv2.isardViewer import isardViewer +isardviewer = isardViewer() + +# ~ from ..auth.authentication import Password + +import bcrypt,string,random + +from .apiv2_exc import * + +# ~ import threading +#import concurrent.futures +from .ds import DS +ds = DS() + +class ApiV2(): + def __init__(self): + self.au=auth() + + def UserExists(self,user_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) is None: + raise UserNotFound + + def UserCreate(self, provider, category_id, user_uid, user_username, role_id, group_id, password=False, photo='', email=''): + # password=False generates a random password + with app.app_context(): + id = provider+'-'+category_id+'-'+user_uid+'-'+user_username + if r.table('users').get(id).run(db.conn) != None: + raise UserExists + + if r.table('roles').get(role_id).run(db.conn) is None: raise RoleNotFound + if r.table('categories').get(category_id).run(db.conn) is None: raise CategoryNotFound + group = r.table('groups').get(group_id).run(db.conn) + if group is None: raise GroupNotFound + + if password == False: + password = '' + else: + password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + user = {'id': id, + 'name': user_username, + 'uid': user_uid, + 'provider': provider, + 'active': True, + 'accessed': time.time(), + 'username': user_username, + 'password': password, + 'role': role_id, + 'category': category_id, + 'group': group_id, + 'email': email, + 'photo': photo, + 'default_templates':[], + 'quota': group['quota'], # 10GB + } + if not _check(r.table('users').insert(user).run(db.conn),'inserted'): + raise NewUserNotInserted #, conflict='update').run(db.conn) + return user['id'] + + def UserUpdate(self, user_id, user_name, user_email='', user_photo=''): + self.UserExists(user_id) + if not _check(r.table('users').get(user_id).update({'name':user_name, 'email':user_email, 'photo':user_photo}).run(db.conn),'replaced'): + raise UpdateFailed + + def UserTemplates(self,user_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) == None: + raise UserNotFound + try: + with app.app_context(): + ud=r.table('users').get(user_id).run(db.conn) + if ud == None: + raise UserNotFound + with app.app_context(): + data1 = r.table('domains').get_all('base', index='kind').order_by('name').pluck({'id','name','allowed','kind','group','icon','user','description'}).run(db.conn) + data2 = r.table('domains').filter(r.row['kind'].match("template")).order_by('name').pluck({'id','name','allowed','kind','group','icon','user','description'}).run(db.conn) + data = data1+data2 + alloweds=[] + for d in data: + with app.app_context(): + d['username']=r.table('users').get(d['user']).pluck('name').run(db.conn)['name'] + if ud['role']=='admin': + alloweds.append(d) + continue + if d['user']==ud['id']: + alloweds.append(d) + continue + if d['allowed']['roles'] is not False: + if len(d['allowed']['roles'])==0: + alloweds.append(d) + continue + else: + if ud['role'] in d['allowed']['roles']: + alloweds.append(d) + continue + if d['allowed']['categories'] is not False: + if len(d['allowed']['categories'])==0: + alloweds.append(d) + continue + else: + if ud['category'] in d['allowed']['categories']: + alloweds.append(d) + continue + if d['allowed']['groups'] is not False: + if len(d['allowed']['groups'])==0: + alloweds.append(d) + continue + else: + if ud['group'] in d['allowed']['groups']: + alloweds.append(d) + continue + if d['allowed']['users'] is not False: + if len(d['allowed']['users'])==0: + alloweds.append(d) + continue + else: + if ud['id'] in d['allowed']['users']: + alloweds.append(d) + continue + return alloweds + except Exception as e: + raise UserTemplatesError + + def UserDesktops(self,user_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) == None: + raise UserNotFound + try: + with app.app_context(): + return list(r.table('domains').get_all(user_id, index='user').filter({'kind':'desktop'}).order_by('name').pluck({'id','name','icon','user','status','description'}).run(db.conn)) + + except Exception as e: + raise UserDesktopsError + + def UserDelete(self,user_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) is None: + raise UserNotFound + todelete = self._user_delete_checks(user_id) + for d in todelete: + try: + ds.delete_desktop(d['id'],d['status']) + except: + raise + #self._delete_non_persistent(user_id) + if not _check(r.table('users').get(user_id).delete().run(db.conn),"deleted"): + raise UserDeleteFailed + + def _user_delete_checks(self,user_id): + with app.app_context(): + user_desktops = list(r.table("domains").get_all(user_id, index='user').filter({'kind': 'desktop'}).pluck('id','name','kind','user','status','parents').run(db.conn)) + user_templates = list(r.table("domains").get_all(r.args(['base','public_template','user_template']),index='kind').filter({'user':user_id}).pluck('id','name','kind','user','status','parents').run(db.conn)) + derivated = [] + for ut in user_templates: + id = ut['id'] + derivated = derivated + list(r.table('domains').pluck('id','name','kind','user','status','parents').filter(lambda derivates: derivates['parents'].contains(id)).run(db.conn)) + #templates = [t for t in derivated if t['kind'] != "desktop"] + #desktops = [d for d in derivated if d['kind'] == "desktop"] + domains = user_desktops+user_templates+derivated + return [i for n, i in enumerate(domains) if i not in domains[n + 1:]] + + + def Login(self,user_id,user_passwd): + user=self.au._check(user_id,user_passwd) + if user == False: + raise UserLoginFailed + + """ def DesktopNewPersistent(self,user_id,template_id, desktop_name): + parsed_name = _(desktop_name) + desktop_id = '_' + user_id + '_' + parsed_name + with app.app_context(): + desktops = r.db('isard').table('domains').get(desktop_id).run(db.conn) + if len(desktops) != None: + raise DesktopExists + + return self._nonpersistent_desktop_create_and_start(user_id,template_id,desktop_name) """ + + def DesktopNewNonpersistent(self,user_id,template_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) is None: + raise UserNotFound + # Has a desktop with this template? Then return it (start it if stopped) + with app.app_context(): + desktops = list(r.db('isard').table('domains').get_all(user_id, index='user').filter({'from_template':template_id, 'persistent':False}).run(db.conn)) + if len(desktops) == 1: + with app.app_context(): + desktops = list(r.db('isard').table('domains').get_all(user_id, index='user').filter({'from_template':template_id, 'persistent':False}).run(db.conn)) + if len(desktops) == 1: + if desktops[0]['status'] == 'Started': + return desktops[0]['id'] + elif desktops[0]['status'] == 'Stopped': + self.WaitStatus(desktops[0]['id'], 'Stopped','Starting','Started') + return desktops[0]['id'] + + # If not, delete all nonpersistent based desktops from user + ds.delete_non_persistent(user_id,template_id) + + # and get a new nonpersistent desktops from this template + return self._nonpersistent_desktop_create_and_start(user_id,template_id) + + def DesktopViewer(self, desktop_id, protocol): + try: + viewer_txt = isardviewer.viewer_data(desktop_id, protocol, get_cookie=False) + except DesktopNotFound: + raise + except DesktopNotStarted: + raise + except NotAllowed: + raise + except ViewerProtocolNotFound: + raise + except ViewerProtocolNotImplemented: + raise + return viewer_txt + + def DesktopDelete(self, desktop_id): + with app.app_context(): + desktop = r.table('domains').get(desktop_id).run(db.conn) + if desktop == None: + raise DesktopNotFound + ds.delete_desktop(desktop_id, desktop['status']) + + def _nonpersistent_desktop_create_and_start(self, user_id, template_id): + with app.app_context(): + user=r.table('users').get(user_id).run(db.conn) + if user == None: + raise UserNotFound + # Create the domain from that template + desktop_id = self._nonpersistent_desktop_from_tmpl(user_id, user['category'], user['group'], template_id) + if desktop_id is False : + raise DesktopNotCreated + + ds.WaitStatus(desktop_id, 'Any','Any','Started') + return desktop_id + + def _nonpersistent_desktop_from_tmpl(self, user_id, category, group, template_id): + with app.app_context(): + template = r.table('domains').get(template_id).run(db.conn) + if template == None: + raise TemplateNotFound + timestamp = time.strftime("%Y%m%d%H%M%S") + parsed_name=timestamp+'-'+_parse_string(template['name']) + + parent_disk=template['hardware']['disks'][0]['file'] + dir_disk = 'volatiles/'+category+'/'+group+'/'+user_id + disk_filename = parsed_name+'.qcow2' + + create_dict=template['create_dict'] + create_dict['hardware']['disks']=[{'file':dir_disk+'/'+disk_filename, + 'parent':parent_disk}] + create_dict=_parse_media_info(create_dict) + + new_desktop={'id': '_'+user_id+'-'+parsed_name, + 'name': parsed_name, + 'description': template['description'], + 'kind': 'desktop', + 'user': user_id, + 'username': user_id.split('-')[-1], + 'status': 'CreatingAndStarting', + 'detail': None, + 'category': category, + 'group': group, + 'xml': None, + 'icon': template['icon'], + 'server': template['server'], + 'os': template['os'], + 'options': {'viewers':{'spice':{'fullscreen':True}}}, + 'create_dict': {'hardware':create_dict['hardware'], + 'origin': template['id']}, + 'hypervisors_pools': template['hypervisors_pools'], + 'allowed': {'roles': False, + 'categories': False, + 'groups': False, + 'users': False}, + 'persistent':False, + 'from_template':template['id']} + + with app.app_context(): + if _check(r.table('domains').insert(new_desktop).run(db.conn),'inserted'): + return new_desktop['id'] + return False + + def DesktopNewPersistent(self, desktop_name, user_id, memory, vcpus, from_template_id = False, xml_id = False, disk_size = False, iso = False, boot='disk'): + parsed_name = _parse_string(desktop_name) + hardware = {'boot_order': [boot], + 'disks': [], + 'floppies': [], + 'graphics': ['default'], + 'interfaces': ['default'], + 'isos': [], + 'memory': 524288, + 'vcpus': 1, + 'videos': ['default']} + + with app.app_context(): + try: + user=r.table('users').get(user_id).pluck('id','category','group','provider','username','uid').run(db.conn) + except: + raise UserNotFound + if iso != False: + if r.table('media').get(iso).run(db.conn) == None: raise MediaNotFound + if from_template_id != False: + template = r.table('domains').get(from_template_id).run(db.conn) + if template == None: raise TemplateNotFound + xml = None + elif xml_id != False: + xml_data = r.table('virt_install').get(xml_id).run(db.conn) + if xml_data == None: raise XmlNotFound + xml = xml_data['xml'] + else: + raise DesktopPreconditionFailed + + + dir_disk, disk_filename = _disk_path(user, parsed_name) + + if from_template_id == False: + if disk_size == False: + if boot == 'disk': raise NewDesktopNotBootable + if boot == 'cdrom' and iso == False: raise NewDesktopNotBootable + hardware['disks']=[] + else: + hardware['disks']=[{'file':dir_disk+'/'+disk_filename, + 'size':disk_size}] # 15G as a format UNITS NEEDED!!! + status = 'CreatingDiskFromScratch' + parents = [] + else: + hardware['disks']=[{'file':dir_disk+'/'+disk_filename, + 'parent':template['create_dict']['hardware']['disks'][0]['file']}] + status = 'Creating' + parents = template['parents'] if 'parents' in template.keys() else [] + + hardware['boot_order']=[boot] + hardware['isos']=[] if iso == False else [iso] + hardware['vcpus']=vcpus + hardware['memory']=memory*1048576 + + create_dict=_parse_media_info({'hardware':hardware}) + if from_template_id != False: + create_dict['origin']=from_template_id + else: + create_dict['create_from_virt_install_xml'] = xml_id + + new_domain={'id': '_'+user_id+'-'+parsed_name, + 'name': desktop_name, + 'description': 'Api created', + 'kind': 'desktop', + 'user': user['id'], + 'username': user['username'], + 'status': status, + 'detail': None, + 'category': user['category'], + 'group': user['group'], + 'xml': xml, + 'icon': 'linux', + 'server': False, + 'os': 'linux', + 'options': {'viewers':{'spice':{'fullscreen':True}}}, + 'create_dict': create_dict, + 'hypervisors_pools': ['default'], + #'parents': parents, + 'allowed': {'roles': False, + 'categories': False, + 'groups': False, + 'users': False}} + + with app.app_context(): + if r.table('domains').get(new_domain['id']).run(db.conn) == None: + if _check(r.table('domains').insert(new_domain).run(db.conn),'inserted') == False: + raise NewDesktopNotInserted + else: + return new_domain['id'] + else: + raise DesktopExists + + + def TemplateNew(self, template_name, user_id, from_desktop_id): + parsed_name = _parse_string(template_name) + template_id = '_' + user_id + '-' + parsed_name + + with app.app_context(): + try: + user=r.table('users').get(user_id).pluck('id','category','group','provider','username','uid').run(db.conn) + except: + raise UserNotFound + desktop = r.table('domains').get(from_desktop_id).run(db.conn) + if desktop == None: raise DesktopNotFound + + parent_disk=desktop['hardware']['disks'][0]['file'] + + hardware = desktop['create_dict']['hardware'] + + dir_disk, disk_filename = _disk_path(user, parsed_name) + hardware['disks']=[{'file':dir_disk+'/'+disk_filename, + 'parent':parent_disk}] + + create_dict=_parse_media_info({'hardware':hardware}) + create_dict['origin']=from_desktop_id + print(create_dict) + template_dict={'id': template_id, + 'name': template_name, + 'description': 'Api created', + 'kind': 'user_template', + 'user': user['id'], + 'username': user['username'], + 'status': 'CreatingTemplate', + 'detail': None, + 'category': user['category'], + 'group': user['group'], + 'xml': desktop['xml'], #### In desktop creation is + 'icon': desktop['icon'], + 'server': desktop['server'], + 'os': desktop['os'], + 'options': desktop['options'], + 'create_dict': create_dict, + 'hypervisors_pools': ['default'], + 'parents': desktop['parents'] if 'parents' in desktop.keys() else [], + 'allowed': {'roles': False, + 'categories': False, + 'groups': False, + 'users': False}} + + with app.app_context(): + if r.table('domains').get(template_dict['id']).run(db.conn) == None: + + if _check(r.table('domains').get(from_desktop_id).update({"create_dict": {"template_dict": template_dict}, "status": "CreatingTemplate"}).run(db.conn),'replaced') == False: + raise NewTemplateNotInserted + else: + return template_dict['id'] + else: + raise TemplateExists + + + + + + + def CodeSearch(self,code): + with app.app_context(): + found=list(r.table('groups').filter({'enrollment':{'manager':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'role':'manager', 'category':category, 'group':found[0]['id']} + found=list(r.table('groups').filter({'enrollment':{'advanced':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'role':'advanced', 'category':category, 'group':found[0]['id']} + found=list(r.table('groups').filter({'enrollment':{'user':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'role':'user', 'category':category, 'group':found[0]['id']} + raise CodeNotFound + + ''' + HELPERS + ''' + def _parse_string(self, txt): + import re, unicodedata, locale + if type(txt) is not str: + txt = txt.decode('utf-8') + #locale.setlocale(locale.LC_ALL, 'ca_ES') + prog = re.compile("[-_àèìòùáéíóúñçÀÈÌÒÙÁÉÍÓÚÑÇ .a-zA-Z0-9]+$") + if not prog.match(txt): + return False + else: + # ~ Replace accents + txt = ''.join((c for c in unicodedata.normalize('NFD', txt) if unicodedata.category(c) != 'Mn')) + return txt.replace(" ", "_") + + def _disk_path(self, user, parsed_name): + with app.app_context(): + group_uid = r.table('groups').get(user['group']).run(db.conn)['uid'] + + dir_path = user['category']+'/'+group_uid+'/'+user['provider']+'/'+user['uid']+'-'+user['username'] + filename = parsed_name + '.qcow2' + return dir_path,filename + + def _check(self,dict,action): + ''' + These are the actions: + {u'skipped': 0, u'deleted': 1, u'unchanged': 0, u'errors': 0, u'replaced': 0, u'inserted': 0} + ''' + if dict[action]: + return True + if not dict['errors']: return True + return False + + def _random_password(self,length=16): + chars = string.ascii_letters + string.digits + '!@#$*' + rnd = random.SystemRandom() + return ''.join(rnd.choice(chars) for i in range(length)) + + def _parse_media_info(self, create_dict): + medias=['isos','floppies','storage'] + for m in medias: + if m in create_dict['hardware']: + newlist=[] + for item in create_dict['hardware'][m]: + with app.app_context(): + newlist.append(r.table('media').get(item['id']).pluck('id','name','description').run(db.conn)) + create_dict['hardware'][m]=newlist + return create_dict + + + ''' + NOT USED + ''' + def enrollment_gen(self, role, length=6): + if role not in ['manager','advanced','user']: return False + chars = digits + ascii_lowercase + code = False + while code == False: + code = "".join([random.choice(chars) for i in range(length)]) + if self.enrollment_code__check(code) == False: + return code + else: + code = False + + + def enrollment_code__check(self, code): + with app.app_context(): + found=list(r.table('groups').filter({'enrollment':{'manager':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'code':code,'role':'manager', 'category':category, 'group':found[0]['id']} + found=list(r.table('groups').filter({'enrollment':{'advanced':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'code':code,'role':'advanced', 'category':category, 'group':found[0]['id']} + found=list(r.table('groups').filter({'enrollment':{'user':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'code':code,'role':'user', 'category':category, 'group':found[0]['id']} + return False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ~ def get_category_template_id(self,cat): + # ~ with app.app_context(): + # ~ id = r.table('domains').filter(r.row['kind'].match("template")).filter(lambda d: d['allowed']['categories']).order_by('name').pluck('id').run(db.conn) + # ~ if id is None: + # ~ return False + # ~ return id + + def get_template(self,id): + # Get template to create domain + template=None + with app.app_context(): + try: + if False: + id=id+'_'+'user' + template = r.table('domains').get(id).without('xml','history_domain','progress').run(db.conn) + except: + raise TemplateNotFound + if template is None: + # WTF! Asked for a template that does not exist?? + raise TemplateNotFound + return template + + def get_default_template_id(self,user,category,group): + with app.app_context(): + try: + return r.table('users').get(user).run(db.conn)['default_templates'][0] + except: + None + try: + return r.table('groups').get(group).run(db.conn)['default_templates'][0] + except: + None + try: + return r.table('categories').get(category).run(db.conn)['default_templates'][0] + except: + None + return False + # ~ id = r.table('domains').filter(r.row['kind'].match("template")).filter(lambda d: d['allowed']['categories']).order_by('name').pluck('id').run(db.conn) + # ~ if id is None: + # ~ return False + # ~ return id + + def CategoryGet(self, category_id): + category = r.table('categories').get(category_id).run(db.conn) + if category is None: + raise CategoryNotFound + + return { 'name': category['name'] } + + def CreateCategory(self,category_id, quota): + if r.table('categories').get(category_id).run(db.conn) is None: + if create_if_not_exist == False: + raise CategoryNotFound + r.table('categories').insert([{'id': category_id, + 'name': category_id, + 'description': category_id, + 'quota': quota, + }], conflict='update').run(db.conn) + def CreateGroup(self, group_id, quota): + if r.table('groups').get(group_id).run(db.conn) is None: + if create_if_not_exist == False: + raise GroupNotFound + r.table('groups').insert([{'id': group_id, + 'name': group_id, + 'description': group_id, + 'quota': quota, + }], conflict='update').run(db.conn) + + + + + def domain_create_and_start(self, user, category, group, template, custom): + ## StoppingAndDeleting all the user's desktops + # ~ with app.app_context(): + # ~ r.table('domains').get_all(user, index='user').update({'status':'StoppingAndDeleting'}).run(db.conn) + ### Check if already started + with app.app_context(): + desktops = list(r.db('isard').table('domains').get_all(user, index='user').filter({'status':'Started'}).run(db.conn)) + if len(desktops) > 0: + return desktops[0] + self.domain_destroy(user) + + + # Create the domain from that template + desktop_id = self.domain_from_tmpl(user, category, group, template, custom) + if desktop_id is False : + raise NewDesktopNotInserted + + # Wait for domain to be started + # ~ for i in range(0,10): + # ~ time.sleep(1) + # ~ if r.db('isard').table('domains').get(desktop_id).pluck('status').run(db.conn)['status'] == 'Started': + # ~ return True + # ~ i=i+1 + # ~ raise DesktopNotStarted + + # ~ try: + # ~ thread = threading.Thread(target=self.wait_for_domain, args=(desktop_id,)) + # ~ thread.start() + # ~ thread.join() + # ~ except ReqlTimeoutError: + # ~ raise DesktopNotStarted + + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(self.wait_for_domain, desktop_id) + try: + result = future.result() + except ReqlTimeoutError: + raise DesktopNotStarted + except DesktopFailed: + raise + + + + + + + + + def domain_destroy(self, user): + ## StoppingAndDeleting all the user's desktops + with app.app_context(): + r.table('domains').get_all(user, index='user').filter({'status':'Started','persistent':False}).update({'status':'Stopping'}).run(db.conn) + r.table('domains').get_all(user, index='user').filter({'status':'Stopped','persistent':False}).update({'status':'Deleting'}).run(db.conn) + + # ~ r.table('domains').get_all(user, index='user').filter({'status':'StoppingAndDeleting'}).delete().run(db.conn) + # ~ r.table('domains').get_all(user, index='user').filter({'status':'CreatingAndStarting'}).delete().run(db.conn) + # ~ r.table('domains').get_all(user, index='user').update({'status':'StoppingAndDeleting'}).run(db.conn) + + # ~ def domain_destroy_not_started(self, user): + # ~ ## StoppingAndDeleting all the user's desktops but Started (mantain old started desktop) + # ~ r.table("domains").get_all(user, index='user').filter( + # ~ lambda dom: + # ~ (dom["Status"] == "Started") + # ~ ).run(conn) + + + # ~ with app.app_context(): + # ~ r.table('domains').get_all(user, index='user').filter({'status':'StoppingAndDeleting'}).delete().run(db.conn) + # ~ r.table('domains').get_all(user, index='user').filter({'status':'CreatingAndStarting'}).delete().run(db.conn) + # ~ r.table('domains').get_all(user, index='user').update({'status':'StoppingAndDeleting'}).run(db.conn) + + def get_domain_id(self,user): + with app.app_context(): + try: + return list(r.table('domains').get_all(user, index='user').run(db.conn))[0]['id'] + except Exception: + raise + diff --git a/api/srcv2/api/libv2/__unused.py b/api/srcv2/api/libv2/__unused.py new file mode 100644 index 000000000..b35caaed9 --- /dev/null +++ b/api/srcv2/api/libv2/__unused.py @@ -0,0 +1,217 @@ + +''' +NOT USED +''' +def enrollment_gen( role, length=6): + if role not in ['manager','advanced','user']: return False + chars = digits + ascii_lowercase + code = False + while code == False: + code = "".join([random.choice(chars) for i in range(length)]) + if self.enrollment_code_check(code) == False: + return code + else: + code = False + + +def enrollment_code_check( code): + with app.app_context(): + found=list(r.table('groups').filter({'enrollment':{'manager':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'code':code,'role':'manager', 'category':category, 'group':found[0]['id']} + found=list(r.table('groups').filter({'enrollment':{'advanced':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'code':code,'role':'advanced', 'category':category, 'group':found[0]['id']} + found=list(r.table('groups').filter({'enrollment':{'user':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'code':code,'role':'user', 'category':category, 'group':found[0]['id']} + return False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +# ~ def get_category_template_id(cat): + # ~ with app.app_context(): + # ~ id = r.table('domains').filter(r.row['kind'].match("template")).filter(lambda d: d['allowed']['categories']).order_by('name').pluck('id').run(db.conn) + # ~ if id is None: + # ~ return False + # ~ return id + +def get_template(id): + # Get template to create domain + template=None + with app.app_context(): + try: + if False: + id=id+'_'+'user' + template = r.table('domains').get(id).without('xml','history_domain','progress').run(db.conn) + except: + raise TemplateNotFound + if template is None: + # WTF! Asked for a template that does not exist?? + raise TemplateNotFound + return template + +def get_default_template_id(user,category,group): + with app.app_context(): + try: + return r.table('users').get(user).run(db.conn)['default_templates'][0] + except: + None + try: + return r.table('groups').get(group).run(db.conn)['default_templates'][0] + except: + None + try: + return r.table('categories').get(category).run(db.conn)['default_templates'][0] + except: + None + return False + # ~ id = r.table('domains').filter(r.row['kind'].match("template")).filter(lambda d: d['allowed']['categories']).order_by('name').pluck('id').run(db.conn) + # ~ if id is None: + # ~ return False + # ~ return id + +def CategoryGet( category_id): + category = r.table('categories').get(category_id).run(db.conn) + if category is None: + raise CategoryNotFound + + return { 'name': category['name'] } + +def CreateCategory(category_id, quota): + if r.table('categories').get(category_id).run(db.conn) is None: + if create_if_not_exist == False: + raise CategoryNotFound + r.table('categories').insert([{'id': category_id, + 'name': category_id, + 'description': category_id, + 'quota': quota, + }], conflict='update').run(db.conn) +def CreateGroup( group_id, quota): + if r.table('groups').get(group_id).run(db.conn) is None: + if create_if_not_exist == False: + raise GroupNotFound + r.table('groups').insert([{'id': group_id, + 'name': group_id, + 'description': group_id, + 'quota': quota, + }], conflict='update').run(db.conn) + + + + +def domain_create_and_start( user, category, group, template, custom): + ## StoppingAndDeleting all the user's desktops + # ~ with app.app_context(): + # ~ r.table('domains').get_all(user, index='user').update({'status':'StoppingAndDeleting'}).run(db.conn) + ### Check if already started + with app.app_context(): + desktops = list(r.db('isard').table('domains').get_all(user, index='user').filter({'status':'Started'}).run(db.conn)) + if len(desktops) > 0: + return desktops[0] + self.domain_destroy(user) + + + # Create the domain from that template + desktop_id = self.domain_from_tmpl(user, category, group, template, custom) + if desktop_id is False : + raise NewDesktopNotInserted + + # Wait for domain to be started + # ~ for i in range(0,10): + # ~ time.sleep(1) + # ~ if r.db('isard').table('domains').get(desktop_id).pluck('status').run(db.conn)['status'] == 'Started': + # ~ return True + # ~ i=i+1 + # ~ raise DesktopNotStarted + + # ~ try: + # ~ thread = threading.Thread(target=self.wait_for_domain, args=(desktop_id,)) + # ~ thread.start() + # ~ thread.join() + # ~ except ReqlTimeoutError: + # ~ raise DesktopNotStarted + + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(self.wait_for_domain, desktop_id) + try: + result = future.result() + except ReqlTimeoutError: + raise DesktopNotStarted + except DesktopFailed: + raise + + + + + + + + +def domain_destroy( user): + ## StoppingAndDeleting all the user's desktops + with app.app_context(): + r.table('domains').get_all(user, index='user').filter({'status':'Started','persistent':False}).update({'status':'Stopping'}).run(db.conn) + r.table('domains').get_all(user, index='user').filter({'status':'Stopped','persistent':False}).update({'status':'Deleting'}).run(db.conn) + + # ~ r.table('domains').get_all(user, index='user').filter({'status':'StoppingAndDeleting'}).delete().run(db.conn) + # ~ r.table('domains').get_all(user, index='user').filter({'status':'CreatingAndStarting'}).delete().run(db.conn) + # ~ r.table('domains').get_all(user, index='user').update({'status':'StoppingAndDeleting'}).run(db.conn) + +# ~ def domain_destroy_not_started( user): + # ~ ## StoppingAndDeleting all the user's desktops but Started (mantain old started desktop) + # ~ r.table("domains").get_all(user, index='user').filter( + # ~ lambda dom: + # ~ (dom["Status"] == "Started") + # ~ ).run(conn) + + + # ~ with app.app_context(): + # ~ r.table('domains').get_all(user, index='user').filter({'status':'StoppingAndDeleting'}).delete().run(db.conn) + # ~ r.table('domains').get_all(user, index='user').filter({'status':'CreatingAndStarting'}).delete().run(db.conn) + # ~ r.table('domains').get_all(user, index='user').update({'status':'StoppingAndDeleting'}).run(db.conn) + + def get_domain_id(self,user): + with app.app_context(): + try: + return list(r.table('domains').get_all(user, index='user').run(db.conn))[0]['id'] + except Exception: + raise diff --git a/api/srcv2/api/libv2/api_deployments.py b/api/srcv2/api/libv2/api_deployments.py new file mode 100644 index 000000000..6f33dd196 --- /dev/null +++ b/api/srcv2/api/libv2/api_deployments.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +import logging +import traceback + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from ..auth.authentication import * + +from ..libv2.isardViewer import isardViewer +isardviewer = isardViewer() + +from .apiv2_exc import * + +from .helpers import ( + _check, + _parse_string, + _parse_media_info, + _disk_path, + _random_password, +) + +from .ds import DS +ds = DS() + +from ..libv2.isardViewer import isardViewer +isardviewer = isardViewer() + +class ApiDeployments(): + def __init__(self): + self.au=auth() + + def List(self,user_id): + with app.app_context(): + deployments = list(r.table('deployments').get_all(user_id,index='user').pluck('id','name').merge(lambda deployment: + { + "totalDesktops": r.table('domains').get_all(deployment['id'],index='tag').count(), + "startedDesktops": r.table('domains').get_all(deployment['id'],index='tag').filter({'status':'Started'}).count() + } + ).run(db.conn)) + return deployments + + def Get(self,user_id,deployment_id): + with app.app_context(): + if user_id != r.table('deployments').get(deployment_id).pluck('user').run(db.conn)['user']: raise + desktops = list(r.table('domains').get_all(deployment_id,index='tag').pluck('id','user','name','description','status','create_dict').merge(lambda desktop: + { + "userName": r.table('users').get(desktop['user']).pluck('name')['name'] + } + ).run(db.conn)) + for desktop in desktops: + desktop['state']=desktop.pop('status') + if desktop['state'] == 'Started': + # We only return the direct browser url. + # TODO: Check if it has RDP and send RDP instead of vnc? + desktop['viewer'] = isardviewer.viewer_data( + desktop['id'], 'vnc-html5', get_cookie=False, get_dict=True + ) + desktop["viewers"] = [] + if "default" in desktop["create_dict"]["hardware"]["videos"]: + desktop["viewers"].extend(["spice", "browser"]) + if "wireguard" in desktop["create_dict"]["hardware"]["interfaces"]: + desktop["ip"] = d.get("viewer", {}).get("guest_ip") + if not desktop["ip"]: + desktop["state"] = "WaitingIP" + if desktop["os"].startswith("win"): + desktop["viewers"].extend(["rdp", "rdp-html5"]) + desktop.pop('create_dict') + return desktops + + + \ No newline at end of file diff --git a/api/srcv2/api/libv2/api_desktops_common.py b/api/srcv2/api/libv2/api_desktops_common.py new file mode 100644 index 000000000..d8cbf93ed --- /dev/null +++ b/api/srcv2/api/libv2/api_desktops_common.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB + +r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +import logging as log + +from .flask_rethink import RDB + +db = RDB(app) +db.init_app(app) + +from ..libv2.isardViewer import isardViewer + +isardviewer = isardViewer() + +from .apiv2_exc import * + +from .ds import DS + +ds = DS() + +from .helpers import _check, _parse_string, _parse_media_info, _disk_path + +import secrets + +class ApiDesktopsCommon: + def __init__(self): + None + + def DesktopViewer(self, desktop_id, protocol, get_cookie=False): + if protocol in ['url','file']: + direct_protocol = protocol + protocol = 'vnc-html5' + else: + direct_protocol = False + + try: + viewer_txt = isardviewer.viewer_data( + desktop_id, protocol, get_cookie=get_cookie + ) + except DesktopNotFound: + raise + except DesktopNotStarted: + raise + except NotAllowed: + raise + except ViewerProtocolNotFound: + raise + except ViewerProtocolNotImplemented: + raise + + if not direct_protocol: + return viewer_txt + else: + return self.DesktopDirectViewer(desktop_id, viewer_txt, direct_protocol) + + def DesktopViewerFromToken(self, token): + with app.app_context(): + domains = list(r.table("domains").filter({"jumperurl": token}).run(db.conn)) + domains=[d for d in domains if d.get("tag_visible", True)] + if len(domains) == 0: + raise DesktopNotFound + if len(domains) == 1: + try: + if domains[0]["status"] in ["Started", "Failed"]: + viewers = { + "vmName": domains[0]["name"], + "vmDescription": domains[0]["description"], + "spice-client": self.DesktopViewer( + domains[0]["id"], "spice-client", get_cookie=True + ), + "vnc-html5": self.DesktopViewer( + domains[0]["id"], "vnc-html5", get_cookie=True + ), + } + return viewers + elif domains[0]["status"] == "Stopped": + ds.WaitStatus(domains[0]["id"], "Stopped", "Starting", "Started") + viewers = { + "vmName": domains[0]["name"], + "vmDescription": domains[0]["description"], + "spice-client": self.DesktopViewer( + domains[0]["id"], "spice-client", get_cookie=True + ), + "vnc-html5": self.DesktopViewer( + domains[0]["id"], "vnc-html5", get_cookie=True + ), + } + return viewers + except: + raise + raise + + def DesktopDirectViewer(self, desktop_id, viewer_txt, protocol): + log.error(viewer_txt) + viewer_uri=viewer_txt['viewer'][0].split('/viewer/')[0]+'/vw/' + + jumpertoken=False + with app.app_context(): + try: + jumpertoken = r.table("domains").get(desktop_id).pluck('jumperurl').run(db.conn)['jumperurl'] + except: + pass + if jumpertoken == False: + jumpertoken = self.gen_jumpertoken(desktop_id) + + return {'kind': protocol,'viewer':viewer_uri+jumpertoken+'?protocol='+protocol, 'cookie': False} + + def gen_jumpertoken(self, desktop_id, length=128): + code = False + while code == False: + code = secrets.token_urlsafe(length) + found=list(r.table('domains').filter({'jumperurl':code}).run(db.conn)) + if len(found) == 0: + with app.app_context(): + r.table('domains').get(desktop_id).update({'jumperurl':code}).run(db.conn) + return code + return False \ No newline at end of file diff --git a/api/srcv2/api/libv2/api_desktops_nonpersistent.py b/api/srcv2/api/libv2/api_desktops_nonpersistent.py new file mode 100644 index 000000000..ee7bd1e2f --- /dev/null +++ b/api/srcv2/api/libv2/api_desktops_nonpersistent.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +import logging as log + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from ..libv2.isardViewer import isardViewer +isardviewer = isardViewer() + +from .apiv2_exc import * + +from .ds import DS +ds = DS() + +from .helpers import _check, _parse_string, _parse_media_info, _disk_path + +class ApiDesktopsNonPersistent(): + def __init__(self): + None + + def New(self,user_id,template_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) is None: + raise UserNotFound + # Has a desktop with this template? Then return it (start it if stopped) + with app.app_context(): + desktops = list(r.db('isard').table('domains').get_all(user_id, index='user').filter({'from_template':template_id, 'persistent':False}).run(db.conn)) + if len(desktops) == 1: + if desktops[0]['status'] == 'Started': + return desktops[0]['id'] + elif desktops[0]['status'] == 'Stopped': + ds.WaitStatus(desktops[0]['id'], 'Stopped','Starting','Started') + return desktops[0]['id'] + + # If not, delete all nonpersistent desktops based on this template from user + ds.delete_non_persistent(user_id,template_id) + + # and get a new nonpersistent desktops from this template + return self._nonpersistent_desktop_create_and_start(user_id,template_id) + + def Delete(self, desktop_id): + with app.app_context(): + desktop = r.table('domains').get(desktop_id).run(db.conn) + if desktop == None: + raise DesktopNotFound + ds.delete_desktop(desktop_id, desktop['status']) + + def DeleteOthers(self, user_id, template_id): + '''Will leave only one nonpersistent desktops form template `template_id` + + :param user_id: User ID + :param template_id: Template ID + :return: None + ''' + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) is None: + raise UserNotFound + + ####### Get how many desktops are from this template and leave only one + with app.app_context(): + desktops = list(r.db('isard').table('domains').get_all(user_id, index='user').filter({'kind':'desktop','from_template':template_id, 'persistent':False}).order_by(r.desc('accessed')).run(db.conn)) + # This situation should not happen as there should only be a maximum of 1 non persistent desktop + # So we delete all but the first one [0] as the descendant order_by lets this as the newer desktop + if len(desktops) > 1: + for i in range(1,len(desktops)-1): + ## We delete all and return the first as the order is descendant (first is the newer desktop) + ds.delete_desktop(desktops[i]['id']) + + # No desktop already in system + if len(desktops) == 0: + raise DesktopNotFound + # Desktop, but stopped + if desktops[0]['status'] == 'Stopped': + raise DesktopNotStarted + + def _nonpersistent_desktop_create_and_start(self, user_id, template_id): + with app.app_context(): + user=r.table('users').get(user_id).run(db.conn) + if user == None: + raise UserNotFound + # Create the domain from that template + desktop_id = self._nonpersistent_desktop_from_tmpl(user_id, user['category'], user['group'], template_id) + if desktop_id is False : + raise DesktopNotCreated + + ds.WaitStatus(desktop_id, 'Any','Any','Started') + return desktop_id + + def _nonpersistent_desktop_from_tmpl(self, user_id, category, group, template_id): + with app.app_context(): + template = r.table('domains').get(template_id).run(db.conn) + if template == None: + raise TemplateNotFound + timestamp = time.strftime("%Y%m%d%H%M%S") + parsed_name=(timestamp+'-'+_parse_string(template['name']))[:40] + + parent_disk=template['hardware']['disks'][0]['file'] + dir_disk = 'volatiles/'+category+'/'+group+'/'+user_id + disk_filename = parsed_name+'.qcow2' + + create_dict=template['create_dict'] + create_dict['hardware']['disks']=[{'file':dir_disk+'/'+disk_filename, + 'parent':parent_disk}] + create_dict=_parse_media_info(create_dict) + + new_desktop={'id': '_'+user_id+'-'+parsed_name, + 'name': parsed_name, + 'description': template['description'], + 'kind': 'desktop', + 'user': user_id, + 'username': user_id.split('-')[-1], + 'status': 'CreatingAndStarting', + 'detail': None, + 'category': category, + 'group': group, + 'xml': None, + 'icon': template['icon'], + 'server': template['server'], + 'os': template['os'], + 'options': {'viewers':{'spice':{'fullscreen':True}}}, + 'create_dict': {'hardware':create_dict['hardware'], + 'origin': template['id']}, + 'hypervisors_pools': template['hypervisors_pools'], + 'allowed': {'roles': False, + 'categories': False, + 'groups': False, + 'users': False}, + 'accessed': time.time(), + 'persistent':False, + 'from_template':template['id']} + + with app.app_context(): + if _check(r.table('domains').insert(new_desktop).run(db.conn),'inserted'): + return new_desktop['id'] + return False + + def DesktopStart(self, desktop_id): + ds.WaitStatus(desktop_id, 'Any','Any','Started') + + + \ No newline at end of file diff --git a/api/srcv2/api/libv2/api_desktops_persistent.py b/api/srcv2/api/libv2/api_desktops_persistent.py new file mode 100644 index 000000000..7fc2c4466 --- /dev/null +++ b/api/srcv2/api/libv2/api_desktops_persistent.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +import logging as log + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from ..libv2.isardViewer import isardViewer +isardviewer = isardViewer() + +from .apiv2_exc import * + +from .ds import DS +ds = DS() + +from .helpers import _check, _parse_string, _parse_media_info, _disk_path + +class ApiDesktopsPersistent(): + def __init__(self): + None + + def Delete(self, desktop_id): + with app.app_context(): + desktop = r.table('domains').get(desktop_id).run(db.conn) + if desktop == None: + raise DesktopNotFound + ds.delete_desktop(desktop_id, desktop['status']) + + def New(self, desktop_name, user_id, memory, vcpus, kind = 'desktop', from_template_id = False, xml_id = False, xml_definition = False, disk_size = False, disk_path = False, parent_disk_path=False, iso = False, boot='disk'): + if kind not in ['desktop', 'user_template']: + raise NewDesktopNotInserted + parsed_name = _parse_string(desktop_name) + hardware = {'boot_order': [boot], + 'disks': [], + 'floppies': [], + 'graphics': ['default'], + 'interfaces': ['default'], + 'isos': [], + 'memory': 524288, + 'vcpus': 1, + 'videos': ['default']} + + with app.app_context(): + try: + user=r.table('users').get(user_id).pluck('id','category','group','provider','username','uid').run(db.conn) + except: + raise UserNotFound + if iso != False: + if r.table('media').get(iso).run(db.conn) == None: raise MediaNotFound + if from_template_id != False: + template = r.table('domains').get(from_template_id).run(db.conn) + if template == None: raise TemplateNotFound + xml = None + elif xml_id != False: + xml_data = r.table('virt_install').get(xml_id).run(db.conn) + if xml_data == None: raise XmlNotFound + xml = xml_data['xml'] + elif xml_definition != False: + xml = xml_definition + else: + raise DesktopPreconditionFailed + + + dir_disk, disk_filename = _disk_path(user, parsed_name) + + if from_template_id == False: + if disk_size == False: + if boot == 'disk': raise NewDesktopNotBootable + if boot == 'cdrom' and iso == False: raise NewDesktopNotBootable + hardware['disks']=[] + else: + hardware['disks']=[{'file':dir_disk+'/'+disk_filename, + 'size':disk_size}] # 15G as a format UNITS NEEDED!!! + status = 'CreatingDiskFromScratch' + parents = [] + if disk_path: + if not parent_disk_path: + parent_disk_path = '' + hardware['disks'] = [{ + 'file': disk_path, + 'parent': parent_disk_path + }] + status = 'Updating' + else: + hardware['disks']=[{'file':dir_disk+'/'+disk_filename, + 'parent':template['create_dict']['hardware']['disks'][0]['file']}] + status = 'Creating' + parents = template['parents'] if 'parents' in template.keys() else [] + + hardware['boot_order']=[boot] + hardware['isos']=[] if iso == False else [iso] + hardware['vcpus']=vcpus + hardware['memory']=memory*1048576 + + create_dict=_parse_media_info({'hardware':hardware}) + if from_template_id != False: + create_dict['origin']=from_template_id + else: + create_dict['create_from_virt_install_xml'] = xml_id + + new_domain={'id': '_'+user_id+'-'+parsed_name, + 'name': desktop_name, + 'description': 'Api created', + 'kind': kind, + 'user': user['id'], + 'username': user['username'], + 'status': status, + 'detail': None, + 'category': user['category'], + 'group': user['group'], + 'xml': xml, + 'icon': 'linux', + 'server': False, + 'os': 'linux', + 'options': {'viewers':{'spice':{'fullscreen':True}}}, + 'create_dict': create_dict, + 'hypervisors_pools': ['default'], + #'parents': parents, + 'allowed': {'roles': False, + 'categories': False, + 'groups': False, + 'users': False}} + + with app.app_context(): + if r.table('domains').get(new_domain['id']).run(db.conn) == None: + if _check(r.table('domains').insert(new_domain).run(db.conn),'inserted') == False: + raise NewDesktopNotInserted + else: + return new_domain['id'] + else: + raise DesktopExists + + def UserDesktop(self, desktop_id): + try: + with app.app_context(): + return r.table('domains').get(desktop_id).pluck('user').run(db.conn)['user'] + except: + raise DesktopNotFound + + def Start(self, desktop_id): + with app.app_context(): + desktop = r.table('domains').get(desktop_id).run(db.conn) + if desktop['status'] == 'Started': + return desktop_id + if desktop['status'] not in ['Stopped', 'Failed']: + raise DesktopActionFailed + if desktop == None: + raise DesktopNotFound + # Start the domain + ds.WaitStatus(desktop_id, 'Any', 'Starting', 'Started') + return desktop_id + + def Stop(self, desktop_id): + with app.app_context(): + desktop = r.table('domains').get(desktop_id).run(db.conn) + if desktop['status'] == 'Stopped': + return desktop_id + if desktop['status'] != 'Started': + raise DesktopActionFailed + if desktop == None: + raise DesktopNotFound + # Start the domain + ds.WaitStatus(desktop_id, 'Any', 'Shutting-down', 'Stopped') + return desktop_id diff --git a/api/srcv2/api/libv2/api_sundry.py b/api/srcv2/api/libv2/api_sundry.py new file mode 100644 index 000000000..d824df3d7 --- /dev/null +++ b/api/srcv2/api/libv2/api_sundry.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +import logging as log + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from ..auth.authentication import * + +from ..libv2.isardViewer import isardViewer +isardviewer = isardViewer() + +from .apiv2_exc import * + +from .helpers import _check, _parse_string, _parse_media_info, _disk_path + +from .ds import DS +ds = DS() + +from .helpers import _check, _random_password + +class ApiSundry(): + def __init__(self): + None + + def UpdateGuestAddr(self, domain_id, data): + with app.app_context(): + if not _check(r.table('domains').get(domain_id).update(data).run(db.conn),'replaced'): + raise UpdateFailed diff --git a/api/srcv2/api/libv2/api_templates.py b/api/srcv2/api/libv2/api_templates.py new file mode 100644 index 000000000..be33a6193 --- /dev/null +++ b/api/srcv2/api/libv2/api_templates.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError +import logging as log + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from .apiv2_exc import * + +from .ds import DS +ds = DS() + +from .helpers import _check, _parse_string, _parse_media_info, _disk_path + +class ApiTemplates(): + def __init__(self): + None + + + + def New(self, template_name, user_id, from_desktop_id): + parsed_name = _parse_string(template_name) + template_id = '_' + user_id + '-' + parsed_name + + with app.app_context(): + try: + user=r.table('users').get(user_id).pluck('id','category','group','provider','username','uid').run(db.conn) + except: + raise UserNotFound + desktop = r.table('domains').get(from_desktop_id).run(db.conn) + if desktop == None: raise DesktopNotFound + + parent_disk=desktop['hardware']['disks'][0]['file'] + + hardware = desktop['create_dict']['hardware'] + + dir_disk, disk_filename = _disk_path(user, parsed_name) + hardware['disks']=[{'file':dir_disk+'/'+disk_filename, + 'parent':parent_disk}] + + create_dict=_parse_media_info({'hardware':hardware}) + create_dict['origin']=from_desktop_id + print(create_dict) + template_dict={'id': template_id, + 'name': template_name, + 'description': 'Api created', + 'kind': 'user_template', + 'user': user['id'], + 'username': user['username'], + 'status': 'CreatingTemplate', + 'detail': None, + 'category': user['category'], + 'group': user['group'], + 'xml': desktop['xml'], #### In desktop creation is + 'icon': desktop['icon'], + 'server': desktop['server'], + 'os': desktop['os'], + 'options': desktop['options'], + 'create_dict': create_dict, + 'hypervisors_pools': ['default'], + 'parents': desktop['parents'] if 'parents' in desktop.keys() else [], + 'allowed': {'roles': False, + 'categories': False, + 'groups': False, + 'users': False}} + + with app.app_context(): + if r.table('domains').get(template_dict['id']).run(db.conn) == None: + + if _check(r.table('domains').get(from_desktop_id).update({"create_dict": {"template_dict": template_dict}, "status": "CreatingTemplate"}).run(db.conn),'replaced') == False: + raise NewTemplateNotInserted + else: + return template_dict['id'] + else: + raise TemplateExists + + + def Get(self,template_id): + with app.app_context(): + try: + return r.table('domains').get(template_id).pluck('id','name','icon','image','description').run(db.conn) + except: + return UserTemplatesError + + + + diff --git a/api/srcv2/api/libv2/api_users.py b/api/srcv2/api/libv2/api_users.py new file mode 100644 index 000000000..e533112d5 --- /dev/null +++ b/api/srcv2/api/libv2/api_users.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +import logging +import traceback + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from ..auth.authentication import * + +from ..libv2.isardViewer import isardViewer +isardviewer = isardViewer() + +from .apiv2_exc import * + +from .helpers import ( + _check, + _parse_string, + _parse_media_info, + _disk_path, + _random_password, +) + +from .ds import DS +ds = DS() + + +def check_category_domain(category_id, domain): + with app.app_context(): + allowed_domain = ( + r.table("categories") + .get(category_id) + .pluck("allowed_domain") + .run(db.conn) + .get("allowed_domain") + ) + return not allowed_domain or domain == allowed_domain + + +class ApiUsers(): + def __init__(self): + self.au=auth() + + def Login(self,user_id,user_passwd): + user=self.au._check(user_id,user_passwd) + if user == False: + raise UserLoginFailed + return user.id + + def Exists(self,user_id): + with app.app_context(): + user = r.table('users').get(user_id).run(db.conn) + if user is None: + raise UserNotFound + return user + + def Create(self, provider, category_id, user_uid, user_username, name, role_id, group_id, password=False, encrypted_password=False, photo='', email=''): + # password=False generates a random password + with app.app_context(): + id = provider+'-'+category_id+'-'+user_uid+'-'+user_username + if r.table('users').get(id).run(db.conn) != None: + raise UserExists + + if r.table('roles').get(role_id).run(db.conn) is None: raise RoleNotFound + if r.table('categories').get(category_id).run(db.conn) is None: raise CategoryNotFound + group = r.table('groups').get(group_id).run(db.conn) + if group is None: raise GroupNotFound + + if password == False: + password = _random_password() + else: + bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + if encrypted_password != False: + password = encrypted_password + + user = {'id': id, + 'name': name, + 'uid': user_uid, + 'provider': provider, + 'active': True, + 'accessed': time.time(), + 'username': user_username, + 'password': password, + 'role': role_id, + 'category': category_id, + 'group': group_id, + 'email': email, + 'photo': photo, + 'default_templates':[], + 'quota': group['quota'], # 10GB + } + if not _check(r.table('users').insert(user).run(db.conn),'inserted'): + raise NewUserNotInserted #, conflict='update').run(db.conn) + return user['id'] + + def Update(self, user_id, user_name=False, user_email=False, user_photo=False): + self.Exists(user_id) + with app.app_context(): + user = r.table("users").get(user_id).run(db.conn) + if user is None: + raise UserNotFound + update_values = {} + if user_name: + update_values["name"] = user_name + if user_email: + update_values["email"] = user_email + if user_photo: + update_values["photo"] = user_photo + if update_values: + if not _check( + r.table("users").get(user_id).update(update_values).run(db.conn), + "replaced", + ): + raise UpdateFailed + + def Templates(self,user_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) == None: + raise UserNotFound + try: + with app.app_context(): + ud=r.table('users').get(user_id).run(db.conn) + if ud == None: + raise UserNotFound + with app.app_context(): + data1 = r.table('domains').get_all('base', index='kind').order_by('name').pluck({'id','name','allowed','kind','group','icon','user','description'}).run(db.conn) + data2 = r.table('domains').filter(r.row['kind'].match("template")).order_by('name').pluck({'id','name','allowed','kind','group','icon','user','description'}).run(db.conn) + data = data1+data2 + alloweds=[] + for d in data: + with app.app_context(): + d['username']=r.table('users').get(d['user']).pluck('name').run(db.conn)['name'] + if ud['role']=='admin': + alloweds.append(d) + continue + if d['user']==ud['id']: + alloweds.append(d) + continue + if d['allowed']['roles'] is not False: + if len(d['allowed']['roles'])==0: + alloweds.append(d) + continue + else: + if ud['role'] in d['allowed']['roles']: + alloweds.append(d) + continue + if d['allowed']['categories'] is not False: + if len(d['allowed']['categories'])==0: + alloweds.append(d) + continue + else: + if ud['category'] in d['allowed']['categories']: + alloweds.append(d) + continue + if d['allowed']['groups'] is not False: + if len(d['allowed']['groups'])==0: + alloweds.append(d) + continue + else: + if ud['group'] in d['allowed']['groups']: + alloweds.append(d) + continue + if d['allowed']['users'] is not False: + if len(d['allowed']['users'])==0: + alloweds.append(d) + continue + else: + if ud['id'] in d['allowed']['users']: + alloweds.append(d) + continue + return alloweds + except Exception as e: + raise UserTemplatesError + + def Desktops(self,user_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) == None: + raise UserNotFound + try: + with app.app_context(): + desktops = list( + r.table("domains") + .get_all(user_id, index="user") + .filter({"kind": "desktop"}) + .order_by("name") + .pluck( + [ + "id", + "name", + "icon", + "image", + "user", + "status", + "description", + "parents", + "persistent", + "os", + "tag_visible", + {"viewer": "guest_ip"}, + {"create_dict": {"hardware": ["interfaces", "videos"]}}, + ] + ) + .run(db.conn) + ) + modified_desktops = [] + for d in desktops: + if not d.get("tag_visible", True): + continue + if d["status"] not in ["Started", "Failed"]: + d["status"] = "Stopped" + d["image"] = d.get("image", None) + d["from_template"] = d.get("parents", [None])[-1] + if d.get("persistent", True): + d["type"] = "persistent" + else: + d["type"] = "nonpersistent" + d["viewers"] = [] + if d["status"] == "Started": + if "default" in d["create_dict"]["hardware"]["videos"]: + d["viewers"].extend(["spice", "browser"]) + if "wireguard" in d["create_dict"]["hardware"]["interfaces"]: + d["ip"] = d.get("viewer", {}).get("guest_ip") + if not d["ip"]: + d["status"] = "WaitingIP" + if d["os"].startswith("win"): + d["viewers"].extend(["rdp", "rdp-html5"]) + modified_desktops.append(d) + return modified_desktops + except Exception as e: + error = traceback.format_exc() + logging.error(error) + raise UserDesktopsError + + def Delete(self,user_id): + with app.app_context(): + if r.table('users').get(user_id).run(db.conn) is None: + raise UserNotFound + todelete = self._user_delete_checks(user_id) + for d in todelete: + try: + ds.delete_desktop(d['id'],d['status']) + except: + raise + #self._delete_non_persistent(user_id) + if not _check(r.table('users').get(user_id).delete().run(db.conn),"deleted"): + raise UserDeleteFailed + + def _user_delete_checks(self,user_id): + with app.app_context(): + user_desktops = list(r.table("domains").get_all(user_id, index='user').filter({'kind': 'desktop'}).pluck('id','name','kind','user','status','parents').run(db.conn)) + user_templates = list(r.table("domains").get_all(r.args(['base','public_template','user_template']),index='kind').filter({'user':user_id}).pluck('id','name','kind','user','status','parents').run(db.conn)) + derivated = [] + for ut in user_templates: + id = ut['id'] + derivated = derivated + list(r.table('domains').pluck('id','name','kind','user','status','parents').filter(lambda derivates: derivates['parents'].contains(id)).run(db.conn)) + #templates = [t for t in derivated if t['kind'] != "desktop"] + #desktops = [d for d in derivated if d['kind'] == "desktop"] + domains = user_desktops+user_templates+derivated + return [i for n, i in enumerate(domains) if i not in domains[n + 1:]] + + def CodeSearch(self,code): + with app.app_context(): + found=list(r.table('groups').filter({'enrollment':{'manager':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'role':'manager', 'category':category, 'group':found[0]['id']} + found=list(r.table('groups').filter({'enrollment':{'advanced':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'role':'advanced', 'category':category, 'group':found[0]['id']} + found=list(r.table('groups').filter({'enrollment':{'user':code}}).run(db.conn)) + if len(found) > 0: + category = found[0]['parent_category'] #found[0]['id'].split('_')[0] + return {'role':'user', 'category':category, 'group':found[0]['id']} + raise CodeNotFound + + def CategoryGet(self,category_id): + with app.app_context(): + category = r.table('categories').get(category_id).run(db.conn) + if category is None: + raise CategoryNotFound + + return { 'name': category['name'] } + + +### USER Schema + + def CategoryCreate(self,category_name,group_name=False,category_limits=False,category_quota=False,group_quota=False): + category_id=_parse_string(category_name) + if group_name: + group_id=_parse_string(group_name) + else: + group_name='Main' + group_id='main' + with app.app_context(): + category = r.table('categories').get(category_id).run(db.conn) + if category == None: + category = { + "description": "" , + "id": category_id , + "limits": category_limits , + "name": category_name , + "quota": category_quota + } + r.table('categories').insert(category, conflict='update').run(db.conn) + + group = r.table('groups').get(category_id+'-'+group_id).run(db.conn) + if group == None: + group = { + "description": "["+category['name']+"]" , + "id": category_id+'-'+group_id , + "limits": False , + "parent_category": category_id, + "uid": group_id, + "name": group_name, + "enrollment": {'manager':False, 'advanced':False, 'user':False}, + "quota": group_quota + } + r.table('groups').insert(group, conflict='update').run(db.conn) + return category_id + + def GroupCreate(self,category_id,group_name,category_limits=False,category_quota=False,group_quota=False): + group_id=_parse_string(group_name) + with app.app_context(): + category = r.table('categories').get(category_id).run(db.conn) + if category == None: return False + + group = r.table('groups').get(category_id+'-'+group_id).run(db.conn) + if group == None: + group = { + "description": "["+category['name']+"]" , + "id": category_id+'-'+group_id , + "limits": False , + "parent_category": category_id, + "uid": group_id, + "name": group_name, + "enrollment": {'manager':False, 'advanced':False, 'user':False}, + "quota": group_quota + } + r.table('groups').insert(group, conflict='update').run(db.conn) + return category_id+'-'+group_id + + def CategoriesGet(self): + with app.app_context(): + return list(r.table('categories').pluck({'id','name','frontend'}).filter({'frontend':True}).order_by('name').run(db.conn)) diff --git a/api/srcv2/api/libv2/api_xml.py b/api/srcv2/api/libv2/api_xml.py new file mode 100644 index 000000000..a9209f71e --- /dev/null +++ b/api/srcv2/api/libv2/api_xml.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# coding=utf-8 +# +# Copyright 2017-2020 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +from api import app + +from rethinkdb import RethinkDB + +r = RethinkDB() + + +from .flask_rethink import RDB + +db = RDB(app) +db.init_app(app) + +from ..libv2.isardViewer import isardViewer + +isardviewer = isardViewer() + +from .apiv2_exc import XmlNotFound + +from .ds import DS + +ds = DS() + + +class ApiXml: + def __init__(self): + None + + def VirtInstallGet(self, id): + with app.app_context(): + virt_install = r.table("virt_install").get(id).run(db.conn) + if virt_install is None: + raise XmlNotFound + return virt_install diff --git a/api/srcv2/api/libv2/apiv2_exc.py b/api/srcv2/api/libv2/apiv2_exc.py new file mode 100644 index 000000000..04235eaf9 --- /dev/null +++ b/api/srcv2/api/libv2/apiv2_exc.py @@ -0,0 +1,120 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!/usr/bin/env python +# coding=utf-8 + +class UserNotFound(Exception): + pass + +class UserExists(Exception): + pass + +class UserTemplatesError(Exception): + pass + +class UserDesktopsError(Exception): + pass + +class UserLoginFailed(Exception): + pass + +class UpdateFailed(Exception): + pass + +class DesktopNotCreated(Exception): + pass + +class DesktopWaitFailed(Exception): + pass + +class DesktopActionTimeout(Exception): + pass + +class DesktopActionFailed(Exception): + pass + +class DesktopNotFound(Exception): + pass + +class DesktopNotStarted(Exception): + pass + +class DesktopPreconditionFailed(Exception): + pass + +class DesktopExists(Exception): + pass + +class NotAllowed(Exception): + pass + +class ViewerProtocolNotImplemented(Exception): + pass + +class ViewerProtocolNotFound(Exception): + pass + +class RoleNotFound(Exception): + pass + +class CategoryNotFound(Exception): + pass + +class GroupNotFound(Exception): + pass + +class UserDeleteFailed(Exception): + pass + + + +class UserTemplateNotFound(Exception): + pass + +class TemplateNotFound(Exception): + pass + +class TemplateExists(Exception): + pass + +class NewUserNotInserted(Exception): + pass + +class NewDesktopNotInserted(Exception): + pass + +class NewTemplateNotInserted(Exception): + pass + + +class DesktopFailed(Exception): + pass + + + +class HypervisorPoolNotFound(Exception): + pass + +class DomainHypervisorSSLPortNotFound(Exception): + pass + +class DomainHypervisorPortNotFound(Exception): + pass + + +class CodeNotFound(Exception): + pass + + + +class NewDesktopNotBootable(Exception): + pass + +class MediaNotFound(Exception): + pass + +class XmlNotFound(Exception): + pass diff --git a/api/srcv2/api/libv2/carbon.py b/api/srcv2/api/libv2/carbon.py new file mode 100644 index 000000000..f26bc7d52 --- /dev/null +++ b/api/srcv2/api/libv2/carbon.py @@ -0,0 +1,54 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!/usr/bin/env python +# coding=utf-8 +import time, os +import socket +import pickle +import struct +import logging as log + +class Carbon(object): + def __init__(self): + self.HOSTNAME='isard-api' + self.SERVER=os.environ['STATS_HOSTNAME'] + self.PORT=2004 + None + + def send(self,dict): + dict={'api':dict} + try: + sender = self.conn() + package = pickle.dumps(self.transform(dict), 1) + size = struct.pack('!L', len(package)) + sender.sendall(size) + sender.sendall(package) + return True + except Exception as e: + # ~ print(str(e)) + log.error("Could not connect to carbon host "+self.SERVER) + return False + + def transform(self,dicts): + tuples = ([]) + now = int(time.time()) + for k,d in dicts.items(): + if d is False: continue + key='isard.sysstats.'+self.HOSTNAME+'.'+k + for item,v in d.items(): + if type(v) is bool: + v = 1 if v is True else 0 + tuples.append((key+'.'+item, (now, v))) + return tuples + + def conn(self): + s = socket.socket() + s.settimeout(2) + try: + s.connect((self.SERVER, self.PORT)) + return s + except socket.error as e: + raise diff --git a/api/srcv2/api/libv2/ds.py b/api/srcv2/api/libv2/ds.py new file mode 100644 index 000000000..e906981f5 --- /dev/null +++ b/api/srcv2/api/libv2/ds.py @@ -0,0 +1,130 @@ +import time +from api import app +from datetime import datetime, timedelta +import pprint + +#import pem +#from OpenSSL import crypto + +# ~ from contextlib import closing + +#import rethinkdb as r +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError +# ~ from ..libv1.log import * +import logging as log + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +from .apiv2_exc import * + +import concurrent.futures + +from .helpers import _check + +class DS(): + def __init__(self): + None + + def delete_desktop(self,desktop_id, status): + if status == 'Started': + transition_status = 'Stopping' + final_status = 'Stopped' + try: + self.WaitStatus(desktop_id, status, transition_status, final_status) + status = 'Stopped' + except ReqlTimeoutError: + with app.app_context(): + r.table('domains').get(desktop_id).update({'status':'Failed'}).run(db.conn) + status = 'Failed' + except DesktopWaitFailed: + with app.app_context(): + r.table('domains').get(desktop_id).update({'status':'Failed'}).run(db.conn) + status = 'Failed' + + if status in ['Stopped', 'Failed']: + transition_status = 'Deleting' + final_status = 'Deleted' + try: + self.WaitStatus(desktop_id, status, transition_status, final_status) + except ReqlTimeoutError: + with app.app_context(): + r.table('domains').get(desktop_id).delete().run(db.conn) + except DesktopWaitFailed: + with app.app_context(): + r.table('domains').get(desktop_id).delete().run(db.conn) + return + + with app.app_context(): + r.table('domains').get(desktop_id).update({'status':'Failed'}).run(db.conn) + transition_status = 'Deleting' + final_status = 'Deleted' + + try: + self.WaitStatus(desktop_id, 'Failed', transition_status, final_status) + except ReqlTimeoutError: + with app.app_context(): + r.table('domains').get(desktop_id).delete().run(db.conn) + except DesktopWaitFailed: + with app.app_context(): + r.table('domains').get(desktop_id).delete().run(db.conn) + + def delete_non_persistent(self, user_id, template=False): + ## StoppingAndDeleting all the user's desktops + if template == False: + with app.app_context(): + desktops_to_delete = r.table('domains').get_all(user_id, index='user').filter({'persistent':False}).without('create_domain','xml','history_domain').run(db.conn) + else: + with app.app_context(): + desktops_to_delete = r.table('domains').get_all(user_id, index='user').filter({'from_template':template,'persistent':False}).without('create_domain','xml','history_domain').run(db.conn) + for desktop in desktops_to_delete: + ds.delete_desktop(desktop['id'],desktop['status']) + + def WaitStatus(self, desktop_id, original_status, transition_status, final_status): + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(lambda p: self._wait_for_domain_status(*p), [desktop_id, original_status, transition_status, final_status]) + try: + result = future.result() + except ReqlTimeoutError: + raise DesktopActionTimeout + except DesktopWaitFailed: + raise DesktopActionFailed + return True + + def _wait_for_domain_status(self, desktop_id, original_status, transition_status, final_status): + with app.app_context(): + # Prepare changes + if final_status == 'Deleted': + changestatus = r.table('domains').get(desktop_id).changes().filter({'new_val': None}).run(db.conn) + elif final_status == 'Started': + changestatus = r.table('domains').get(desktop_id).changes().filter({'new_val':{'status':final_status}}).has_fields({'new_val': {'viewer': {'tls':{'host-subject':True}}}}).run(db.conn) + else: + changestatus = r.table('domains').get(desktop_id).changes().filter({'new_val':{'status':final_status}}).run(db.conn) + + # Start transition + if transition_status != 'Any': + status = r.table('domains').get(desktop_id).update({'status':transition_status}).run(db.conn) + + if _check(status,'replaced') == False: + raise DesktopPreconditionFailed + + # Get change + try: + doc = changestatus.next(wait=5) + except ReqlTimeoutError: + raise + if final_status != 'Deleted': + if doc['new_val']['status'] == 'Failed': + raise DesktopWaitFailed + + def _check(self,dict,action): + ''' + These are the actions: + {u'skipped': 0, u'deleted': 1, u'unchanged': 0, u'errors': 0, u'replaced': 0, u'inserted': 0} + ''' + if dict[action]: + return True + if not dict['errors']: return True + return False \ No newline at end of file diff --git a/api/srcv2/api/libv2/flask_rethink.py b/api/srcv2/api/libv2/flask_rethink.py new file mode 100644 index 000000000..a1bd315aa --- /dev/null +++ b/api/srcv2/api/libv2/flask_rethink.py @@ -0,0 +1,47 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!/usr/bin/env python +# coding=utf-8 + +from rethinkdb import RethinkDB; r = RethinkDB() +#import rethinkdb as r +from flask import current_app + +# ~ from ..libv1.log import * +import logging as log + +# Since no older versions than 0.9 are supported for Flask, this is safe +from flask import _app_ctx_stack as stack + + +class RDB(object): + + def __init__(self, app=None, db=None): + self.app = app + self.db = db + if app != None: + self.init_app(app) + + def init_app(self, app): + @app.teardown_appcontext + def teardown(exception): + ctx = stack.top + if hasattr(ctx, 'rethinkdb'): + ctx.rethinkdb.close() + + def connect(self): + return r.connect(host=current_app.config['RETHINKDB_HOST'], + port=current_app.config['RETHINKDB_PORT'], + auth_key=current_app.config['RETHINKDB_AUTH'], + db=self.db or current_app.config['RETHINKDB_DB']) + + @property + def conn(self): + ctx = stack.top + if ctx != None: + if not hasattr(ctx, 'rethinkdb'): + ctx.rethinkdb = self.connect() + return ctx.rethinkdb diff --git a/api/srcv2/api/libv2/helpers.py b/api/srcv2/api/libv2/helpers.py new file mode 100644 index 000000000..82e11f2fe --- /dev/null +++ b/api/srcv2/api/libv2/helpers.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError + +import logging as log + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + + +import bcrypt,string,random + +from .apiv2_exc import * + + +def _parse_string( txt): + import re, unicodedata, locale + if type(txt) is not str: + txt = txt.decode('utf-8') + #locale.setlocale(locale.LC_ALL, 'ca_ES') + prog = re.compile("[-_àèìòùáéíóúñçÀÈÌÒÙÁÉÍÓÚÑÇ .a-zA-Z0-9]+$") + if not prog.match(txt): + return False + else: + # ~ Replace accents + txt = ''.join((c for c in unicodedata.normalize('NFD', txt) if unicodedata.category(c) != 'Mn')) + return txt.replace(" ", "_") + +def _disk_path( user, parsed_name): + with app.app_context(): + group_uid = r.table('groups').get(user['group']).run(db.conn)['uid'] + + dir_path = user['category']+'/'+group_uid+'/'+user['provider']+'/'+user['uid']+'-'+user['username'] + filename = parsed_name + '.qcow2' + return dir_path,filename + +def _check(dict,action): + ''' + These are the actions: + {u'skipped': 0, u'deleted': 1, u'unchanged': 0, u'errors': 0, u'replaced': 0, u'inserted': 0} + ''' + if dict[action]: + return True + if not dict['errors']: return True + return False + +def _random_password(length=16): + chars = string.ascii_letters + string.digits + '!@#$*' + rnd = random.SystemRandom() + return ''.join(rnd.choice(chars) for i in range(length)) + +def _parse_media_info( create_dict): + medias=['isos','floppies','storage'] + for m in medias: + if m in create_dict['hardware']: + newlist=[] + for item in create_dict['hardware'][m]: + with app.app_context(): + newlist.append(r.table('media').get(item['id']).pluck('id','name','description').run(db.conn)) + create_dict['hardware'][m]=newlist + return create_dict + diff --git a/api/srcv2/api/libv2/isardViewer.py b/api/srcv2/api/libv2/isardViewer.py new file mode 100644 index 000000000..090f1105e --- /dev/null +++ b/api/srcv2/api/libv2/isardViewer.py @@ -0,0 +1,397 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!/usr/bin/env python +# coding=utf-8 +# ~ import sys, json +# ~ from webapp import app +# ~ import rethinkdb as r +# ~ from ..lib.log import * + +# ~ from .flask_rethink import RethinkDB +# ~ db = RethinkDB(app) +# ~ db.init_app(app) + +# ~ from .admin_api import flatten +# ~ from netaddr import IPNetwork, IPAddress + +# ~ from ..lib.viewer_exc import * + +# ~ from http.cookies import SimpleCookie +# ~ import base64 + +import sys,base64,json +import os +from api import app +from ..libv2.log import * + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError, ReqlNonExistenceError +import urllib + +from ..libv2.flask_rethink import RDB +db = RDB(app) +db.init_app(app) + +#from netaddr import IPNetwork, IPAddress + +from ..libv2.apiv2_exc import * + +class isardViewer(): + def __init__(self): + # Offset from base_port == spice + self.spice=0 + self.spice_tls=1 + self.vnc=2 + self.vnc_tls=3 + self.vnc_ws=-198 #5900-200???? + pass + + def viewer_data(self,id,get_viewer='spice-client',current_user=False,default_viewer=False,get_cookie=True,get_dict=False): + try: + domain = r.table('domains').get(id).pluck('id','name','status','viewer','options').run(db.conn) + except ReqlNonExistenceError: + raise DesktopNotFound + if not domain['status'] == 'Started': + raise DesktopNotStarted + + if current_user is not False: + if not id.startswith('_'+current_user.id+'-'): + raise NotAllowed + if 'preferred' not in domain['options']['viewers'].keys() or not domain['options']['viewers']['preferred'] == default_viewer: + r.table('domains').get(id).update({'options':{'viewers':{'preferred':default_viewer}}}).run(db.conn) + + if get_viewer == 'rdp-client': + return {'kind':'file','name':'isard-rdp','ext':'rdp','mime':'application/x-rdp','content':self.get_rdp_file(domain['viewer']['guest_ip'])} + + if get_viewer == 'spice-html5': + if get_cookie: + cookie = base64.b64encode(json.dumps({ + 'web_viewer': { + 'vmName': domain['name'], + 'vmHost': domain['viewer']['proxy_hyper_host'], + 'vmPort': str(domain['viewer']['base_port']+self.spice), + 'host': domain['viewer']['proxy_video'], + 'port': '443', + 'token': domain['viewer']['passwd'] + } + }).encode('utf-8')).decode('utf-8') + uri = 'https://'+domain['viewer']['static']+'/viewer/spice-web-client/', + return {'kind':'url','viewer':uri,'cookie':cookie} + else: + return 'https://'+domain['viewer']['static']+'/viewer/spice-web-client/?vmName='+urllib.parse.quote_plus(domain['name'])+'&vmHost='+domain['viewer']['proxy_hyper_host']+'&host='+domain['viewer']['proxy_video']+'&vmPort='+str(port)+'&passwd='+domain['viewer']['passwd'] + + if get_viewer == 'vnc-html5': + vmPort=str(domain['viewer']['base_port']+self.vnc) + port=str(domain['viewer']['html5_ext_port']) if 'html5_ext_port' in domain['viewer'].keys() else '443' + if get_cookie: + cookie = base64.b64encode(json.dumps({ + 'web_viewer': { + 'vmName': domain['name'], + 'vmHost': domain['viewer']['proxy_hyper_host'], + 'vmPort': vmPort, + 'host': domain['viewer']['proxy_video'], + 'port': port, + 'token': domain['viewer']['passwd'] + } + }).encode('utf-8')).decode('utf-8') + uri = 'https://'+domain['viewer']['static']+'/viewer/noVNC/', + return {'kind':'url','viewer':uri,'cookie':cookie} + elif get_dict: + return { + 'proxy': 'https://'+domain['viewer']['static']+'/viewer/noVNC/', + 'vmName': domain['name'], + 'vmHost': domain['viewer']['proxy_hyper_host'], + 'vmPort': vmPort, + 'host': domain['viewer']['proxy_video'], + 'port': port, + 'token': domain['viewer']['passwd'] + } + else: + return 'https://'+domain['viewer']['static']+'/viewer/noVNC/?vmName='+urllib.parse.quote_plus(domain['name'])+'&vmHost='+domain['viewer']['proxy_hyper_host']+'&host='+domain['viewer']['proxy_video']+'&port='+port+'&vmPort='+vmPort+'&passwd='+domain['viewer']['passwd'] + + if get_viewer == "rdp-html5": + vmPort = str(domain["viewer"]["base_port"] + self.vnc) + port = ( + str(domain["viewer"]["html5_ext_port"]) + if "html5_ext_port" in domain["viewer"].keys() + else "443" + ) + if get_cookie: + cookie = base64.b64encode( + json.dumps( + { + "web_viewer": { + "vmName": domain["name"], + "vmHost": domain["viewer"]["guest_ip"], + "vmUsername": domain["options"]["credentials"][ + "username" + ] + if "credentials" in domain["options"] + else "", + "vmPassword": domain["options"]["credentials"][ + "password" + ] + if "credentials" in domain["options"] + else "", + "host": domain["viewer"]["proxy_video"], + "port": port, + } + } + ).encode("utf-8") + ).decode("utf-8") + uri = (f"https://{domain['viewer']['static']}/Rdp",) + return {"kind": "url", "viewer": uri, "cookie": cookie} + else: + return "https://" + domain["viewer"]["static"] + "/notavailable" + + if get_viewer == 'spice-client': + port=domain['viewer']['base_port']+self.spice_tls + vmPort=domain['viewer']['spice_ext_port'] if 'spice_ext_port' in domain['viewer'].keys() else '80' + consola=self.get_spice_file(domain,vmPort,port) + if get_cookie: + return {'kind':'file','name':'isard-spice','ext':consola[0],'mime':consola[1],'content':consola[2]} + else: + return consola[2] + + if get_viewer == 'vnc-client': + raise ViewerProtocolNotImplemented + if get_viewer == 'vnc-client-macos': + raise ViewerProtocolNotImplemented + + return ViewerProtocolNotFound + + def get_rdp_file(self,ip): + return """full address:s:%s +""" % (ip) + + def get_spice_file(self, domain, port, vmPort): + try: + op_fscr = 1 if domain['options'] is not False and domain['options']['fullscreen'] else 0 + except: + op_fscr = 0 + + # ~ viewer = { 'viewer_static_host': hypervisor['viewer_static_host'], + # ~ 'viewer_proxy_video': hypervisor['viewer_proxy_video'], + # ~ 'viewer_hyper_host': hypervisor['viewer_hyper_host'], + # ~ 'base_port': domain['viewer']['port'], + # ~ 'passwd': domain['viewer']['passwd'], + # ~ 'client_addr': False, + # ~ 'client_since': False, + # ~ } + + + c = '%' + consola = """[virt-viewer] + type=%s + proxy=http://%s:%s + host=%s + password=%s + tls-port=%s + fullscreen=%s + title=%s:%sd - Prem SHIFT+F12 per sortir + enable-smartcard=0 + enable-usb-autoshare=1 + delete-this-file=1 + usb-filter=-1,-1,-1,-1,0 + tls-ciphers=DEFAULT + """ % ( + 'spice', + domain['viewer']['proxy_video'], + port, + domain['viewer']['proxy_hyper_host'], + domain['viewer']['passwd'], + vmPort,op_fscr, + domain['name'] +' (TLS)', + c + ) + + consola = consola + """%shost-subject=%s + %sca=%r + toggle-fullscreen=shift+f11 + release-cursor=shift+f12 + secure-attention=ctrl+alt+end + secure-channels=main;inputs;cursor;playback;record;display;usbredir;smartcard""" % ( + '' if domain['viewer']['tls']['host-subject'] is not False else ';', domain['viewer']['tls']['host-subject'], '' if domain['viewer']['tls']['certificate'] is not False else ';', domain['viewer']['tls']['certificate']) + + consola = consola.replace("'", "") + return 'vv','application/x-virt-viewer',consola + + +##### VNC NOT DONE + + def get_domain_vnc_data(self, domain, hostnames, viewer, port): + try: + cookie = base64.b64encode(json.dumps({ + 'web_viewer': { + 'host': hostnames['host'], + 'port': str(int(port)), + 'token': domain['viewer']['passwd'] + } + }).encode('utf-8')).decode('utf-8') + + return { + 'uri': 'https://' + hostnames['proxy'] + '/static/noVNC', + 'cookie': cookie + } + + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(exc_type, fname, exc_tb.tb_lineno) + log.error('Viewer for domain '+domain['name']+' exception:'+str(e)) + return False + + # ~ def get_domain_vnc_data(self, domain, hostnames, viewer, port, tlsport, selfsigned, remote_addr=False): + # ~ try: + # ~ ''' VNC does not have ssl. Only in websockets is available ''' + # ~ if viewer['defaultMode'] == "Secure" and domain['viewer']['port_spice_ssl'] is not False: + # ~ return {'host':hostname, + # ~ 'name': domain['name'], + # ~ 'port': port, + # ~ 'wsport': str(int(port)+500), + # ~ 'ca':viewer['certificate'], + # ~ 'domain':viewer['domain'], + # ~ 'host-subject':viewer['host-subject'], + # ~ 'passwd': domain['viewer']['passwd'], + # ~ 'uri': 'https:///wsviewer/novnclite'+selfsigned+'/?host='+hostname+'&port='+str(int(port))+'&password='+domain['viewer']['passwd'], + # ~ 'options': domain['options']['viewers']['vnc'] if 'vnc' in domain['options']['viewers'].keys() else False} + # ~ if viewer['defaultMode'] == "Insecure" and domain['viewer']['port_spice'] is not False: + # ~ return {'host':hostname, + # ~ 'name': domain['name'], + # ~ 'port': port, + # ~ 'wsport': str(int(port)+500), + # ~ 'ca':viewer['certificate'], + # ~ 'domain':viewer['domain'], + # ~ 'host-subject':viewer['host-subject'], + # ~ 'passwd': domain['viewer']['passwd'], + # ~ 'uri': 'http:///wsviewer/novnclite/?host='+hostname+'&port='+str(int(port))+'&password='+domain['viewer']['passwd'], + # ~ 'options': domain['options']['viewers']['vnc'] if 'vnc' in domain['options']['viewers'].keys() else False} + # ~ log.error('No available VNC Viewer for domain '+domain['name']+' exception:'+str(e)) + # ~ return False + # ~ except Exception as e: + # ~ exc_type, exc_obj, exc_tb = sys.exc_info() + # ~ fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + # ~ log.error(exc_type, fname, exc_tb.tb_lineno) + # ~ log.error('Viewer for domain '+domain['name']+' exception:'+str(e)) + # ~ return False + + + ##### VNC FILE VIEWER + def get_vnc_file(self, dict, id, clientos, remote_addr=False): + ## Should check if ssl in use: dict['tlsport']: + hostname=dict['host'] + # ~ if dict['tlsport']: + # ~ return False + #~ os='MacOS' + if clientos in ['iOS','Windows','Android','Linux', 'generic', None]: + consola="""[Connection] + Host=%s + Port=%s + Password=%s + + [Options] + UseLocalCursor=1 + UseDesktopResize=1 + FullScreen=1 + FullColour=0 + LowColourLevel=0 + PreferredEncoding=ZRLE + AutoSelect=1 + Shared=0 + SendPtrEvents=1 + SendKeyEvents=1 + SendCutText=1 + AcceptCutText=1 + Emulate3=1 + PointerEventInterval=0 + Monitor= + MenuKey=F8 + """ % (hostname, dict['port'], dict['passwd']) + consola = consola.replace("'", "") + return 'vnc','text/plain',consola + + if clientos in ['MacOS']: + vnc="vnc://"+hostname+":"+dict['passwd']+"@"+hostname+":"+dict['port'] + consola=""" + + + + URL + %s + restorationAttributes + + autoClipboard + + controlMode + 1 + isFullScreen + + quality + 3 + scalingMode + + screenConfiguration + + GlobalIsMixedMode + + GlobalScreen + + Flags + 0 + Frame + {{0, 0}, {1920, 1080}} + Identifier + 0 + Index + 0 + + IsDisplayInfo2 + + IsVNC + + ScaledSelectedScreenRect + (0, 0, 1920, 1080) + Screens + + + Flags + 0 + Frame + {{0, 0}, {1920, 1080}} + Identifier + 0 + Index + 0 + + + + selectedScreen + + Flags + 0 + Frame + {{0, 0}, {1920, 1080}} + Identifier + 0 + Index + 0 + + targetAddress + %s + viewerScaleFactor + 1 + windowContentFrame + {{0, 0}, {1829, 1029}} + windowFrame + {{45, 80}, {1829, 1097}} + + + """ % (vnc,vnc) + consola = consola.replace("'", "") + return 'vncloc','text/plain',consola + + + diff --git a/api/srcv2/api/libv2/isardVpn.py b/api/srcv2/api/libv2/isardVpn.py new file mode 100644 index 000000000..229c38d19 --- /dev/null +++ b/api/srcv2/api/libv2/isardVpn.py @@ -0,0 +1,79 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!/usr/bin/env python +# coding=utf-8 + +import sys,base64,json +from api import app +from ..libv2.log import * + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError +import urllib + +from ..libv2.flask_rethink import RDB +db = RDB(app) +db.init_app(app) + + +class isardVpn(): + def __init__(self): + pass + + def vpn_data(self,vpn,kind,os,current_user=False): + if vpn == 'users': + if current_user == False: return False + wgdata = r.table('users').get(current_user).pluck('vpn').run(db.conn) + elif vpn == 'hypers': + if current_user.role != 'admin': return False + wgdata = r.table('hypervisors').get(current_user.id).pluck('vpn').run(db.conn) + else: + return False + if wgdata == None or 'vpn' not in wgdata.keys(): + return False + ## First up time the wireguard config keys are missing till isard-vpn populates it. + if not getattr(app, 'wireguard_users_keys', False): + sysconfig = r.db('isard').table('config').get(1).run(db.conn) + app.wireguard_users_keys = sysconfig.get('vpn_users', {}).get('wireguard', {}).get('keys', False) + if app.wireguard_users_keys == False: + log.error('There are no wireguard keys in webapp config yet. Try again in a few seconds...') + return False + endpoints=list(r.table('hypervisors').pluck({'viewer': 'static'}).run(db.conn)) + if len(endpoints): + endpoint = endpoints[0]['viewer']['static'] + if kind == 'config': + return {'kind':'file','name':'isard-vpn','ext':'conf','mime':'text/plain','content':self.get_wireguard_file(endpoint,wgdata)} + elif kind == 'install': + ext='sh' if os == 'Linux' else 'vb' + return {'kind':'file','name':'isard-vpn-setup','ext':ext,'mime':'text/plain','content':self.get_wireguard_install_script(endpoint,wgdata,os)} + + return False + + def get_wireguard_file(self,endpoint,peer): + return """[Interface] +Address = %s +PrivateKey = %s + +[Peer] +PublicKey = %s +Endpoint = %s:443 +AllowedIPs = %s +PersistentKeepalive = 21 +""" % (peer['vpn']['wireguard']['Address'],peer['vpn']['wireguard']['keys']['private'],app.wireguard_users_keys['public'],endpoint,peer['vpn']['wireguard']['AllowedIPs']) + + def get_wireguard_install_script(self,endpoint,peer,os): + return """#!/bin/bash +echo "Installing wireguard. Ubuntu/Debian script." +apt install -y wireguard git dh-autoreconf libglib2.0-dev intltool build-essential libgtk-3-dev libnma-dev libsecret-1-dev network-manager-dev resolvconf +git clone https://github.com/max-moser/network-manager-wireguard +cd network-manager-wireguard +./autogen.sh --without-libnm-glib +./configure --without-libnm-glib --prefix=/usr --sysconfdir=/etc --libdir=/usr/lib/x86_64-linux-gnu --libexecdir=/usr/lib/NetworkManager --localstatedir=/var +make +sudo make install +cd .. +echo "%s" > isard-vpn.conf +echo "You have your user vpn configuration to use it with NetworkManager: isard-vpn.conf""" % self.get_wireguard_file(endpoint,peer) \ No newline at end of file diff --git a/api/srcv2/api/libv2/load_config.py b/api/srcv2/api/libv2/load_config.py new file mode 100644 index 000000000..180ea1a08 --- /dev/null +++ b/api/srcv2/api/libv2/load_config.py @@ -0,0 +1,61 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!/usr/bin/env python +# coding=utf-8 + +#~ from ..lib.log import * +#~ import logging as cfglog + +from api import app +# ~ import rethinkdb as r +#~ from flask import current_app + +# ~ from .flask_rethink import RethinkDB +import os, sys +import logging as log + +class loadConfig(): + + def __init__(self, app=None): + None + + def check_db(self): + return True + try: + conn=RethinkDB(None) + conn.connect() + return True + except Exception as e: + print(e) + return False + + def init_app(self, app): + ''' + Read RethinkDB configuration from environ + ''' + try: + app.config.setdefault('RETHINKDB_HOST', os.environ['RETHINKDB_HOST']) + app.config.setdefault('RETHINKDB_PORT', os.environ['RETHINKDB_PORT']) + app.config.setdefault('RETHINKDB_AUTH', '') + app.config.setdefault('RETHINKDB_DB', os.environ['RETHINKDB_DB']) + + app.config.setdefault('LOG_LEVEL', os.environ['LOG_LEVEL']) + app.config.setdefault('LOG_FILE', 'isard-api.log') + app.debug=True if os.environ['LOG_LEVEL'] == 'DEBUG' else False + + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(exc_type, fname, exc_tb.tb_lineno) + log.error('Missing parameters!') + print('Missing parameters!') + return False + print('Initial configuration loaded...') + if self.check_db() is False: + print('No database found!!!!!!!!!!') + print('Using database connection {} and database {}'.format(app.config['RETHINKDB_HOST']+':'+app.config['RETHINKDB_PORT'],app.config['RETHINKDB_DB'])) + return False + return True diff --git a/api/srcv2/api/libv2/log.py b/api/srcv2/api/libv2/log.py new file mode 100644 index 000000000..713dce9e6 --- /dev/null +++ b/api/srcv2/api/libv2/log.py @@ -0,0 +1,27 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!/usr/bin/env python +# coding=utf-8 + +import logging as log +import configparser +import os +from api import app + +try: + LOG_LEVEL = app.config['LOG_LEVEL'] +except Exception as e: + LOG_LEVEL = 'INFO' + +# LOG FORMATS +LOG_FORMAT='%(asctime)s %(msecs)d - %(levelname)s - %(threadName)s: %(message)s' +LOG_DATE_FORMAT='%Y/%m/%d %H:%M:%S' +LOG_LEVEL_NUM = log.getLevelName(LOG_LEVEL) +# ~ log.basicConfig(format=LOG_FORMAT,datefmt=LOG_DATE_FORMAT,level=LOG_LEVEL_NUM) + +# ~ log.basicConfig(filename='logs/webapp.log', + # ~ filemode='a', + # ~ format=LOG_FORMAT,datefmt=LOG_DATE_FORMAT,level=LOG_LEVEL_NUM) diff --git a/api/srcv2/api/libv2/quotas.py b/api/srcv2/api/libv2/quotas.py new file mode 100644 index 000000000..c01490363 --- /dev/null +++ b/api/srcv2/api/libv2/quotas.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError +# ~ from ..libv1.log import * +import logging as log + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + + +from .quotas_exc import * +from .webapp_quotas import WebappQuotas +wq=WebappQuotas() + +class Quotas(): + def __init__(self): + None + + def UserCreate(self,category_id,group_id): + exces = wq.check_new_autoregistered_user(category_id,group_id) + if exces != False: + if 'category' in exces: + raise QuotaCategoryNewUserExceeded + if 'group' in exces: + raise QuotaGroupNewUserExceeded + + return False + + def DesktopCreate(self,user_id): + exces = wq.check('NewDesktop',user_id) + if exces != False: + if 'category' in exces: + raise QuotaCategoryNewDesktopExceeded + if 'group' in exces: + raise QuotaGroupNewDesktopExceeded + raise QuotaUserNewDesktopExceeded + + return False + + def DesktopStart(self,user_id): + exces = wq.check('NewConcurrent',user_id) + if exces != False: + if 'CPU' in exces: + if 'category' in exces: + raise QuotaCategoryVcpuExceeded + if 'group' in exces: + raise QuotaGroupVcpuExceeded + raise QuotaUserVcpuExceeded + if 'MEMORY' in exces: + if 'category' in exces: + raise QuotaCategoryMemoryExceeded + if 'group' in exces: + raise QuotaGroupMemoryExceeded + raise QuotaUserMemoryExceeded + + if 'category' in exces: + raise QuotaCategoryConcurrentExceeded + if 'group' in exces: + raise QuotaGroupNewConcurrentExceeded + raise QuotaUserConcurrentExceeded + + return False + + def DesktopCreateAndStart(self,user_id): + self.DesktopCreate(user_id) + self.DesktopStart(user_id) + + def TemplateCreate(sefl,user_id): + return False + + def IsoCreate(sefl,user_id): + return False \ No newline at end of file diff --git a/api/srcv2/api/libv2/quotas_exc.py b/api/srcv2/api/libv2/quotas_exc.py new file mode 100644 index 000000000..1879ddcc0 --- /dev/null +++ b/api/srcv2/api/libv2/quotas_exc.py @@ -0,0 +1,57 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!/usr/bin/env python +# coding=utf-8 + +class QuotaCategoryNewUserExceeded(Exception): + pass + +class QuotaGroupNewUserExceeded(Exception): + pass + +## User + +class QuotaUserNewDesktopExceeded(Exception): + pass + +class QuotaUserConcurrentExceeded(Exception): + pass + +class QuotaUserVcpuExceeded(Exception): + pass + +class QuotaUserMemoryExceeded(Exception): + pass + +## Category +class QuotaCategoryNewDesktopExceeded(Exception): + pass + +class QuotaCategoryConcurrentExceeded(Exception): + pass + +class QuotaCategoryVcpuExceeded(Exception): + pass + +class QuotaCategoryMemoryExceeded(Exception): + pass + + + +## Group +class QuotaGroupNewDesktopExceeded(Exception): + pass + +class QuotaGroupConcurrentExceeded(Exception): + pass + +class QuotaGroupVcpuExceeded(Exception): + pass + +class QuotaGroupMemoryExceeded(Exception): + pass + + \ No newline at end of file diff --git a/api/srcv2/api/libv2/telegram.py b/api/srcv2/api/libv2/telegram.py new file mode 100644 index 000000000..fd8fd3693 --- /dev/null +++ b/api/srcv2/api/libv2/telegram.py @@ -0,0 +1,30 @@ +# ~ import schedule +import requests +import time + +def tsend(bot_message): + print(bot_message) + return True + bot_token = '1116693240:AAFo_H5L0nSFruZVSMW4Zl5EmQXtDCyG2MU' + bot_chatID = '451903641' + send_text = 'https://api.telegram.org/bot' + bot_token + '/sendMessage?chat_id=' + bot_chatID + '&parse_mode=Markdown&text=' + bot_message + + response = requests.get(send_text) + + return response.json() + + +# ~ def report(): + # ~ my_balance = 10 ## Replace this number with an API call to fetch your account balance + # ~ my_message = "Current balance is: {}".format(my_balance) ## Customize your message + # ~ telegram_bot_sendtext(my_message) + + + +# ~ schedule.every().day.at("12:00").do(report) + +# ~ while True: + # ~ schedule.run_pending() + # ~ telegram_bot_sendtext('running') + # ~ time.sleep(1) + diff --git a/api/srcv2/api/libv2/webapp_quotas.py b/api/srcv2/api/libv2/webapp_quotas.py new file mode 100644 index 000000000..20d4b5a94 --- /dev/null +++ b/api/srcv2/api/libv2/webapp_quotas.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python +# coding=utf-8 +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 +import time +from api import app +from datetime import datetime, timedelta +import pprint + +from rethinkdb import RethinkDB; r = RethinkDB() +from rethinkdb.errors import ReqlTimeoutError +# ~ from ..libv1.log import * +import logging as log + +from .flask_rethink import RDB +db = RDB(app) +db.init_app(app) + + +from .quotas_exc import * + +class WebappQuotas(): + def __init__(self): + None + + ''' Copy from webapp quotas.py ''' + def get(self,user_id,category_id=False,admin=False): + ''' Used by socketio to inform of user quotas ''' + userquotas = {} + if user_id !=False: + with app.app_context(): + user = r.table('users').get(user_id).run(db.conn) + if user == None: + return userquotas + userquotas = self.process_user_quota(user) + if user['role'] == 'manager': + userquotas['limits'] = self.process_category_limits(user_id,from_user_id=True) + if user['role'] == 'admin': + userquotas['global'] = self.get_admin_usage() + else: + if category_id !=False: + userquotas['limits'] = self.process_category_limits(category_id) + if admin == True: + userquotas['global'] = self.get_admin_usage() + return userquotas + + def process_user_quota(self, user_id): + if isinstance ( user_id , dict ): + user = user_id + user_id= user['id'] + else: + with app.app_context(): + user = r.table('users').get(user_id).pluck('quota').run(db.conn) + + with app.app_context(): + desktops=r.table('domains').get_all(user_id, index='user').filter({'kind': 'desktop'}).count().run(db.conn) + desktopsup=r.table('domains').get_all(user_id, index='user').filter({'kind': 'desktop','status':'Started'}).count().run(db.conn) + templates=r.table('domains').get_all(user_id, index='user').filter({'kind': 'user_template'}).count().run(db.conn) + isos=r.table('media').get_all(user_id, index='user').count().run(db.conn) + + starteds=r.table('domains').get_all(user_id, index='user').filter({'status': 'Started'}).pluck('hardware').run(db.conn) + + vcpus = 0 + memory = 0 + for s in starteds: + vcpus = vcpus + s['hardware']['vcpus'] + memory = memory + s['hardware']['memory'] + memory =memory/1000000 + + if user['quota'] == False: + qpdesktops=qpup=qptemplates=qpisos=qpvcpus=qpmemory=0 + dq=rq=tq=iq=vq=mq=9999 + else: + qpdesktops=desktops*100/user['quota']['desktops'] if user['quota']['desktops'] else 100 + dq=user['quota']['desktops'] + + qpup=desktopsup*100/user['quota']['running'] if user['quota']['running'] else 100 + rq=user['quota']['running'] + + qptemplates=templates*100/user['quota']['templates'] if user['quota']['templates'] else 100 + tq=user['quota']['templates'] + + qpisos=isos*100/user['quota']['isos'] if user['quota']['isos'] else 100 + iq=user['quota']['isos'] + + qpvcpus=vcpus*100/user['quota']['vcpus'] if user['quota']['vcpus'] else 100 + vq=user['quota']['vcpus'] + + qpmemory=memory*100/user['quota']['memory'] if user['quota']['memory'] else 100 # convert GB to KB (domains are in KB by default) + mq=user['quota']['memory'] + + return {'d':desktops, 'dq':dq, 'dqp':int(round(qpdesktops,0)), + 'r':desktopsup,'rq':rq, 'rqp':int(round(qpup,0)), + 't':templates, 'tq':tq, 'tqp':int(round(qptemplates,0)), + 'i':isos, 'iq':iq, 'iqp':int(round(qpisos,0)), + 'v':vcpus, 'vq':vq, 'vqp':int(round(qpvcpus,0)), + 'm':int(round(memory)), 'mq':mq, 'mqp':int(round(qpmemory,0))} + + def process_category_limits(self, id, from_user_id=False, from_group_id=False): + if from_user_id != False: + with app.app_context(): + user = r.table('users').get(id).pluck('category','role').run(db.conn) + id = user['category'] + if from_group_id != False: + with app.app_context(): + id = r.table('groups').get(id).pluck('parent_category').run(db.conn)['parent_category'] + + with app.app_context(): + category = r.table('categories').get(id).run(db.conn) + if category == None or 'limits' not in category.keys() or category['limits'] == False : return False + + with app.app_context(): + desktops=r.table('domains').get_all(category['id'], index='category').filter({'kind': 'desktop'}).count().run(db.conn) + desktopsup=r.table('domains').get_all(category['id'], index='category').filter({'kind': 'desktop','status':'Started'}).count().run(db.conn) + templates=r.table('domains').get_all(category['id'], index='category').filter(r.row['kind'].match('template')).count().run(db.conn) + isos=r.table('media').get_all(category['id'], index='category').count().run(db.conn) + + starteds=r.table('domains').get_all(category['id'], index='category').filter({'status': 'Started'}).pluck('hardware').run(db.conn) + + users = r.table('users').get_all(category['id'], index='category').count().run(db.conn) + + vcpus = 0 + memory = 0 + for s in starteds: + vcpus = vcpus + s['hardware']['vcpus'] + memory = memory + s['hardware']['memory'] + memory =memory/1000000 + + if category['limits'] == False: + qpdesktops=qpup=qptemplates=qpisos=qpvcpus=qpmemory=qpusers=0 + dq=rq=tq=iq=vq=mq=uq=9999 + else: + qpdesktops=desktops*100/category['limits']['desktops'] if category['limits']['desktops'] else 100 + dq=category['limits']['desktops'] + + qpup=desktopsup*100/category['limits']['running'] if category['limits']['running'] else 100 + rq=category['limits']['running'] + + qptemplates=templates*100/category['limits']['templates'] if category['limits']['templates'] else 100 + tq=category['limits']['templates'] + + qpisos=isos*100/category['limits']['isos'] if category['limits']['isos'] else 100 + iq=category['limits']['isos'] + + qpvcpus=vcpus*100/category['limits']['vcpus'] if category['limits']['vcpus'] else 100 + vq=category['limits']['vcpus'] + + qpmemory=memory/category['limits']['memory'] if category['limits']['memory'] else 100 # convert GB to KB (domains are in KB by default) + mq=category['limits']['memory'] + + qpusers=users*100/category['limits']['users'] if category['limits']['users'] else 100 + uq=category['limits']['users'] + + return {'d':desktops, 'dq':dq, 'dqp':int(round(qpdesktops,0)), + 'r':desktopsup,'rq':rq, 'rqp':int(round(qpup,0)), + 't':templates, 'tq':tq, 'tqp':int(round(qptemplates,0)), + 'i':isos, 'iq':iq, 'iqp':int(round(qpisos,0)), + 'v':vcpus, 'vq':vq, 'vqp':int(round(qpvcpus,0)), + 'm':int(round(memory)), 'mq':mq, 'mqp':int(round(qpmemory,0)), + 'u':users, 'uq':uq, 'uqp':int(round(qpusers,0))} + + def process_group_limits(self, id, from_user_id=False): + if from_user_id != False: + with app.app_context(): + user = r.table('users').get(id).pluck('group','role').run(db.conn) + group_id=user['group'] + else: + group_id=id + + with app.app_context(): + group = r.table('groups').get(group_id).run(db.conn) + if group == None or 'limits' not in group.keys() or group['limits'] == False : return False + + with app.app_context(): + desktops=r.table('domains').get_all(group['id'], index='group').filter({'kind': 'desktop'}).count().run(db.conn) + desktopsup=r.table('domains').get_all(group['id'], index='group').filter({'kind': 'desktop','status':'Started'}).count().run(db.conn) + templates=r.table('domains').get_all(group['id'], index='group').filter(r.row['kind'].match('template')).count().run(db.conn) + isos=r.table('media').get_all(group['id'], index='group').count().run(db.conn) + + starteds=r.table('domains').get_all(group['id'], index='group').filter({'status': 'Started'}).pluck('hardware').run(db.conn) + + users = r.table('users').get_all(group['id'], index='group').count().run(db.conn) + + vcpus = 0 + memory = 0 + for s in starteds: + vcpus = vcpus + s['hardware']['vcpus'] + memory = memory + s['hardware']['memory'] + memory =memory/1000000 + + if group['limits'] == False: + qpdesktops=qpup=qptemplates=qpisos=qpvcpus=qpmemory=qpusers=0 + dq=rq=tq=iq=vq=mq=uq=9999 + else: + qpdesktops=desktops*100/group['limits']['desktops'] if group['limits']['desktops'] else 100 + dq=group['limits']['desktops'] + + qpup=desktopsup*100/group['limits']['running'] if group['limits']['running'] else 100 + rq=group['limits']['running'] + + qptemplates=templates*100/group['limits']['templates'] if group['limits']['templates'] else 100 + tq=group['limits']['templates'] + + qpisos=isos*100/group['limits']['isos'] if group['limits']['isos'] else 100 + iq=group['limits']['isos'] + + qpvcpus=vcpus*100/group['limits']['vcpus'] if group['limits']['vcpus'] else 100 + vq=group['limits']['vcpus'] + + qpmemory=memory/group['limits']['memory'] if group['limits']['memory'] else 100 # convert GB to KB (domains are in KB by default) + mq=group['limits']['memory'] + + qpusers=users*100/group['limits']['users'] if group['limits']['users'] else 100 + uq=group['limits']['users'] + + return {'d':desktops, 'dq':dq, 'dqp':int(round(qpdesktops,0)), + 'r':desktopsup,'rq':rq, 'rqp':int(round(qpup,0)), + 't':templates, 'tq':tq, 'tqp':int(round(qptemplates,0)), + 'i':isos, 'iq':iq, 'iqp':int(round(qpisos,0)), + 'v':vcpus, 'vq':vq, 'vqp':int(round(qpvcpus,0)), + 'm':int(round(memory)), 'mq':mq, 'mqp':int(round(qpmemory,0)), + 'u':users, 'uq':uq, 'uqp':int(round(qpusers,0))} + + def get_admin_usage(self): + with app.app_context(): + desktops=r.table('domains').get_all('desktop', index='kind').count().run(db.conn) + desktopsup=r.table('domains').get_all('Started', index='status').count().run(db.conn) + templates=r.table('domains').filter(r.row['kind'].match('template')).count().run(db.conn) + isos=r.table('media').count().run(db.conn) + starteds=r.table('domains').get_all('Started', index='status').pluck('hardware').run(db.conn) + vcpus = 0 + memory = 0 + for s in starteds: + vcpus = vcpus + s['hardware']['vcpus'] + memory = memory + s['hardware']['memory'] + memory =memory/1000000 + + users = r.table('users').count().run(db.conn) + + return {'d':desktops, + 'r':desktopsup, + 't':templates, + 'i':isos, + 'v':vcpus, + 'm':int(round(memory)), + 'u':users} + + def check(self,item,user_id,amount=1): + ''' All common events should call here and check if quota/limits have exceeded already.''' + user = self.process_user_quota(user_id) + group = self.process_group_limits(user_id, from_user_id=True) + category = self.process_category_limits(user_id, from_user_id=True) + + if item == "NewDesktop": + if user != False and float(user['dqp']) >= 100: + return 'New user desktop quota exceeded.' + if group != False and float(group['dqp']) >= 100: + return 'New group desktop quota exceeded.' + if category != False and float(category['dqp']) >= 100: + return 'New category desktop quota exceeded.' + + if item == "NewConcurrent": + if user != False: + if float(user['rqp']) >= 100: + return 'New user concurrent desktop quota exceeded.' + if float(user['vqp']) >= 100: + return 'New user concurrent desktop quota for vCPU exceeded.' + if float(user['mqp']) >= 100: + return 'New user concurrent desktop quota for MEMORY exceeded.' + if group != False: + if float(group['rqp']) >= 100: + return 'New group concurrent desktop quota exceeded.' + if float(group['vqp']) >= 100: + return 'New group concurrent desktop quota for vCPU exceeded.' + if float(group['mqp']) >= 100: + return 'New group concurrent desktop quota for MEMORY exceeded.' + if category != False: + if float(category['rqp']) >= 100: + return 'New category concurrent desktop quota exceeded.' + if float(category['vqp']) >= 100: + return 'New category concurrent desktop quota for vCPU exceeded.' + if float(category['mqp']) >= 100: + return 'New category concurrent desktop quota for MEMORY exceeded.' + + if item == "NewTemplate": + if user != False and float(user['tqp']) >= 100: + return 'New user template quota exceeded.' + if group != False and float(group['tqp']) >= 100: + return 'New group template quota exceeded.' + if category != False and float(category['tqp']) >= 100: + return 'New category template quota exceeded.' + + if item == "NewIso": + if user != False and float(user['iqp']) >= 100: + return 'New user iso upload quota exceeded.' + if group != False and float(group['iqp']) >= 100: + return 'New group iso upload quota exceeded.' + if category != False and float(category['iqp']) >= 100: + return 'New category iso upload quota exceeded.' + + if item == "NewUser": + if group != False and float(group['uqp']) >= 100: + return 'New group add user quota exceeded.' + if category != False and float(category['uqp']) >= 100: + return 'New category add user quota exceeded.' + + if item == "NewUsers": + if group != False and group['u']+amount > group['uq']: + return 'New group add user quota exceeded. You want to insert '+str(amount)+' users and your group already has '+str(group['u'])+'/'+str(group['uq'])+' users.' + if category != False and category['u']+amount > category['uq']: + return 'New category add user quota exceeded. You want to insert '+str(amount)+' users and your category already has '+str(category['u'])+'/'+str(category['uq'])+' users.' + + return False + + def check_new_autoregistered_user(self,category_id,group_id): + ''' All common events should call here and check if quota/limits have exceeded already.''' + group = self.process_group_limits(group_id, from_user_id=False) + category = self.process_category_limits(category_id, from_user_id=False) + + if group != False and float(group['uqp']) >= 100: + return 'New group add user quota exceeded.' + if category != False and float(category['uqp']) >= 100: + return 'New category add user quota exceeded.' + + return False + + ''' Used to edit category/group/user in admin ''' + def get_category(self, category_id): + with app.app_context(): + category = r.table('categories').get(category_id).run(db.conn) + return {'quota': category['quota'], + 'limits': category['limits'] if 'limits' in category else False} + + def get_group(self, group_id): + ### Limits for group will be at least limits for its category + with app.app_context(): + group = r.table('groups').get(group_id).run(db.conn) + limits = group['limits'] + if limits == False: + with app.app_context(): + limits = r.table('categories').get(group['parent_category']).pluck('limits').run(db.conn)['limits'] + return {'quota': group['quota'], + 'limits': limits, ##Category limits as maximum + 'grouplimits': group['limits']} + + def get_user(self, user_id): + with app.app_context(): + user = r.table('users').get(user_id).run(db.conn) + group = r.table('groups').get(user['group']).run(db.conn) + limits = group['limits'] + if limits == False: + with app.app_context(): + limits = r.table('categories').get(group['parent_category']).pluck('limits').run(db.conn)['limits'] + return {'quota': user['quota'], + 'limits': limits} + + def user_hardware_allowed(self, user_id): + dict={} + dict['nets']=app.isardapi.get_alloweds(user_id,'interfaces',pluck=['id','name','description'],order='name') + #~ dict['disks']=app.isardapi.get_alloweds(user_id,'disks',pluck=['id','name','description'],order='name') + dict['graphics']=app.isardapi.get_alloweds(user_id,'graphics',pluck=['id','name','description'],order='name') + dict['videos']=app.isardapi.get_alloweds(user_id,'videos',pluck=['id','name','description'],order='name') + dict['boots']=app.isardapi.get_alloweds(user_id,'boots',pluck=['id','name','description'],order='name') + #dict['forced_hyp'].insert(0,{'id':'default','hostname':'Auto','description':'Hypervisor pool default'}) + dict['qos_id']=app.isardapi.get_alloweds(user_id,'qos_disk',pluck=['id','name','description'],order='name') + + dict['hypervisors_pools']=app.isardapi.get_alloweds(user_id,'hypervisors_pools',pluck=['id','name','description'],order='name') + dict['forced_hyp']=[] + + quota=self.get_user(user_id) + dict={**dict, **quota} + return dict + + def limit_user_hardware_allowed(self,create_dict, user_id): + user_hardware=self.user_hardware_allowed(user_id) + ## Limit the resources to the ones allowed to user + if user_hardware['quota'] != False: + if create_dict['hardware']['vcpus'] > user_hardware['quota']['vcpus']: create_dict['hardware']['vcpus'] = user_hardware['quota']['vcpus'] + if create_dict['hardware']['memory'] > user_hardware['quota']['memory']*1048576: create_dict['hardware']['memory'] = user_hardware['quota']['memory']*1048576 + + if create_dict['hardware']['videos'][0] not in [uh['id'] for uh in user_hardware['videos']]: create_dict['hardware']['videos']=['default'] + if create_dict['hardware']['interfaces'][0] not in [uh['id'] for uh in user_hardware['nets']]: create_dict['hardware']['interfaces'] = ['default'] + + if create_dict['hardware']['graphics'][0] not in [uh['id'] for uh in user_hardware['graphics']]: create_dict['hardware']['graphics']=['default'] + + + if create_dict['hardware']['boot_order'][0] not in [uh['id'] for uh in user_hardware['boots']]: create_dict['hardware']['boot_order']=['hd'] + if create_dict['hardware']['qos_id'] not in [uh['id'] for uh in user_hardware['qos_id']]: create_dict['hardware']['qos_id']='unlimited' + + return create_dict diff --git a/api/srcv2/api/static/logo.svg b/api/srcv2/api/static/logo.svg new file mode 100644 index 000000000..34b2e0e72 --- /dev/null +++ b/api/srcv2/api/static/logo.svg @@ -0,0 +1,44 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/api/srcv2/api/templates/error.html b/api/srcv2/api/templates/error.html new file mode 100644 index 000000000..97f4e2259 --- /dev/null +++ b/api/srcv2/api/templates/error.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + Direct viewer connection | Isard VDI + + + + +
+ +

IsardVDI

+

Direct viewer connection

+

{{ error }}

+
+ + + \ No newline at end of file diff --git a/api/srcv2/api/templates/jumper.html b/api/srcv2/api/templates/jumper.html new file mode 100644 index 000000000..b0c7a0fbb --- /dev/null +++ b/api/srcv2/api/templates/jumper.html @@ -0,0 +1,106 @@ + + + + + + + + + + + + + Direct viewer connection | Isard VDI + + + + + +
+ +

IsardVDI

+

Direct viewer connection

+

{{ vmName|safe }}

+
{{ vmDescription|safe }}
+ + + +
+ + + diff --git a/api/srcv2/api/templates/logo.svg b/api/srcv2/api/templates/logo.svg new file mode 100644 index 000000000..34b2e0e72 --- /dev/null +++ b/api/srcv2/api/templates/logo.svg @@ -0,0 +1,44 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/api/srcv2/api/views/CommonView.py b/api/srcv2/api/views/CommonView.py new file mode 100644 index 000000000..a6dd4c24a --- /dev/null +++ b/api/srcv2/api/views/CommonView.py @@ -0,0 +1,214 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log +import traceback + +from uuid import uuid4 +import time, json +import sys, os +from flask import request +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +from flask import ( + render_template, + Response, + request, + redirect, + url_for, + send_file, + send_from_directory, +) + +# from ..libv2.telegram import tsend +def tsend(txt): + None + + +from ..libv2.carbon import Carbon + +carbon = Carbon() + +from ..libv2.quotas import Quotas + +quotas = Quotas() + +from ..libv2.api_desktops_common import ApiDesktopsCommon + +common = ApiDesktopsCommon() + + +@app.route("/api/v2/desktop//viewer/", methods=["GET"]) +def api_v2_desktop_viewer(desktop_id=False, protocol=False): + if desktop_id == False or protocol == False: + log.error("Incorrect access parameters. Check your query.") + return ( + json.dumps( + {"code": 8, "msg": "Incorrect access parameters. Check your query."} + ), + 401, + {"Content-Type": "application/json"}, + ) + + try: + viewer = common.DesktopViewer(desktop_id, protocol, get_cookie=True) + return json.dumps(viewer), 200, {"Content-Type": "application/json"} + except DesktopNotFound: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", desktop not found" + ) + return ( + json.dumps({"code": 1, "msg": "Desktop viewer: desktop id not found"}), + 404, + {"Content-Type": "application/json"}, + ) + except DesktopNotStarted: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", desktop not started" + ) + return ( + json.dumps({"code": 2, "msg": "Desktop viewer: desktop is not started"}), + 404, + {"Content-Type": "application/json"}, + ) + except NotAllowed: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", viewer access not allowed" + ) + return ( + json.dumps({"code": 3, "msg": "Desktop viewer: desktop id not owned by user"}), + 404, + {"Content-Type": "application/json"}, + ) + except ViewerProtocolNotFound: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", viewer protocol not found" + ) + return ( + json.dumps({"code": 4, "msg": "Desktop viewer: viewer protocol not found"}), + 404, + {"Content-Type": "application/json"}, + ) + except ViewerProtocolNotImplemented: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", viewer protocol not implemented" + ) + return ( + json.dumps({"code": 5, "msg": "Desktop viewer: viewer protocol not implemented"}), + 404, + {"Content-Type": "application/json"}, + ) + except Exception as e: + error = traceback.format_exc() + return ( + json.dumps({"code": 9, "msg": "DesktopViewer general exception: " + error}), + 401, + {"Content-Type": "application/json"}, + ) + +@app.route("/api/v2/desktop//viewers", methods=["GET"]) +def api_v2_desktop_viewers(desktop_id=False, protocol=False): + viewers = [] + for protocol in ['vnc-html5','spice-client']: + try: + viewer = common.DesktopViewer(desktop_id, protocol, get_cookie=True) + viewers.append({**{'protocol':protocol},**viewer}) + except DesktopNotFound: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", desktop not found" + ) + return ( + json.dumps({"code": 1, "msg": "Desktop viewer: desktop id not found"}), + 404, + {"Content-Type": "application/json"}, + ) + except DesktopNotStarted: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", desktop not started" + ) + return ( + json.dumps({"code": 2, "msg": "Desktop viewer: desktop is not started"}), + 404, + {"Content-Type": "application/json"}, + ) + except NotAllowed: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", viewer access not allowed" + ) + return ( + json.dumps({"code": 3, "msg": "Desktop viewer: desktop id not owned by user"}), + 404, + {"Content-Type": "application/json"}, + ) + except ViewerProtocolNotFound: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", viewer protocol not found" + ) + return ( + json.dumps({"code": 4, "msg": "Desktop viewer: viewer protocol not found"}), + 404, + {"Content-Type": "application/json"}, + ) + except ViewerProtocolNotImplemented: + log.error( + "Viewer for desktop " + + desktop_id + + " with protocol " + + protocol + + ", viewer protocol not implemented" + ) + return ( + json.dumps({"code": 5, "msg": "Desktop viewer: viewer protocol not implemented"}), + 404, + {"Content-Type": "application/json"}, + ) + except Exception as e: + error = traceback.format_exc() + return ( + json.dumps({"code": 9, "msg": "DesktopViewer general exception: " + error}), + 401, + {"Content-Type": "application/json"}, + ) + return json.dumps(viewers), 200, {"Content-Type": "application/json"} \ No newline at end of file diff --git a/api/srcv2/api/views/DeploymentsView.py b/api/srcv2/api/views/DeploymentsView.py new file mode 100644 index 000000000..f3c0dda4d --- /dev/null +++ b/api/srcv2/api/views/DeploymentsView.py @@ -0,0 +1,50 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log +import traceback + +from uuid import uuid4 +import time,json +import sys,os +from flask import request, jsonify +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +#from ..libv2.telegram import tsend +def tsend(txt): + None +from ..libv2.carbon import Carbon +carbon = Carbon() + +from ..libv2.quotas import Quotas +quotas = Quotas() + +from ..libv2.api_deployments import ApiDeployments +deployments = ApiDeployments() + + +@app.route('/api/v2/user//deployment/', methods=['GET']) +def api_v2_deployment(user_id,deployment_id): + + try: + deployment = deployments.Get(user_id,deployment_id) + return json.dumps(deployment), 200, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DeploymentGet general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/user//deployments', methods=['GET']) +def api_v2_deployments(user_id): + + try: + deployments_list = deployments.List(user_id) + return json.dumps(deployments_list), 200, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DeploymentsList general exception: " + error }), 401, {'Content-Type': 'application/json'} diff --git a/api/srcv2/api/views/DesktopsNonPersistentView.py b/api/srcv2/api/views/DesktopsNonPersistentView.py new file mode 100644 index 000000000..20c2b6097 --- /dev/null +++ b/api/srcv2/api/views/DesktopsNonPersistentView.py @@ -0,0 +1,187 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log +import traceback + +from uuid import uuid4 +import time,json +import sys,os +from flask import request +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +#from ..libv2.telegram import tsend +def tsend(txt): + None +from ..libv2.carbon import Carbon +carbon = Carbon() + +from ..libv2.quotas import Quotas +quotas = Quotas() + +from ..libv2.api_desktops_nonpersistent import ApiDesktopsNonPersistent +desktops = ApiDesktopsNonPersistent() + +@app.route('/api/v2/desktop', methods=['POST']) +def api_v2_desktop_new(): + try: + user_id = request.form.get('id', type = str) + template_id = request.form.get('template', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + if user_id == None or template_id == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + # Leave only one nonpersistent desktop from this template + try: + desktops.DeleteOthers(user_id,template_id) + + except DesktopNotFound: + try: + quotas.DesktopCreateAndStart(user_id) + except QuotaUserNewDesktopExceeded: + log.error("Quota for user "+user_id+" to create a desktop exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user desktop quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewDesktopExceeded: + log.error("Quota for user "+user_id+" to create a desktop in his group limits is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew group desktop limits CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryNewDesktopExceeded: + log.error("Quota for user "+user_id+" to create a desktop in his category limits is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew category desktop limits CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserConcurrentExceeded: + log.error("Quota for user "+user_id+" to start a desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupConcurrentExceeded: + log.error("Quota for user "+user_id+" to start a desktop in his group is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryConcurrentExceeded: + log.error("Quota for user "+user_id+" to start a desktop is his category exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserVcpuExceeded: + log.error("Quota for user "+user_id+" to allocate vCPU is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupVcpuExceeded: + log.error("Quota for user "+user_id+" to allocate vCPU in his group is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user group limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryVcpuExceeded: + log.error("Quota for user "+user_id+" to allocate vCPU in his category is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserMemoryExceeded: + log.error("Quota for user "+user_id+" to allocate MEMORY is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupMemoryExceeded: + log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user group limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryMemoryExceeded: + log.error("Quota for user "+user_id+" category for desktop MEMORY allocation is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} + + except DesktopNotStarted: + try: + quotas.DesktopStart(user_id) + except QuotaUserConcurrentExceeded: + log.error("Quota for user "+user_id+" to start a desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupConcurrentExceeded: + log.error("Quota for user "+user_id+" to start a desktop in his group is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryConcurrentExceeded: + log.error("Quota for user "+user_id+" to start a desktop is his category exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserVcpuExceeded: + log.error("Quota for user "+user_id+" to allocate vCPU is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupVcpuExceeded: + log.error("Quota for user "+user_id+" to allocate vCPU in his group is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user group limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryVcpuExceeded: + log.error("Quota for user "+user_id+" to allocate vCPU in his category is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserMemoryExceeded: + log.error("Quota for user "+user_id+" to allocate MEMORY is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupMemoryExceeded: + log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user group limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryMemoryExceeded: + log.error("Quota for user "+user_id+" category for desktop MEMORY allocation is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopNew previous checks general exception: " + error }), 401, {'Content-Type': 'application/json'} + + + # So now we have checked if desktop exists and if we can create and/or start it + + try: + now=time.time() + desktop_id = desktops.New(user_id,template_id) + carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) + return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("Desktop for user "+user_id+" from template "+template_id+", user not found") + return json.dumps({"code":1,"msg":"DesktopNew user not found"}), 404, {'Content-Type': 'application/json'} + except TemplateNotFound: + log.error("Desktop for user "+user_id+" from template "+template_id+" template not found.") + return json.dumps({"code":2,"msg":"DesktopNew template not found"}), 404, {'Content-Type': 'application/json'} + except DesktopNotCreated: + log.error("Desktop for user "+user_id+" from template "+template_id+" creation failed.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":1,"msg":"DesktopNew not created"}), 404, {'Content-Type': 'application/json'} + except DesktopActionTimeout: + log.error("Desktop for user "+user_id+" from template "+template_id+" start timeout.") + carbon.send({'create_and_start_time':'100'}) + return ( + json.dumps({"code": 2, "msg": "DesktopNew start timeout"}), + 408, + {"Content-Type": "application/json"}, + ) + except DesktopActionFailed: + log.error("Desktop for user "+user_id+" from template "+template_id+" start failed.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":3,"msg":"DesktopNew start failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopNew general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/desktop/', methods=['DELETE']) +def api_v2_desktop_delete(desktop_id=False): + if desktop_id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + now=time.time() + desktops.Delete(desktop_id) + carbon.send({'delete_time':str(round(time.time()-now,2))}) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except DesktopNotFound: + log.error("Desktop delete "+desktop_id+", desktop not found") + return json.dumps({"code":1,"msg":"Desktop delete id not found"}), 404, {'Content-Type': 'application/json'} + except DesktopDeleteFailed: + log.error("Desktop delete "+desktop_id+", desktop delete failed") + return json.dumps({"code":5,"msg":"Desktop delete deleting failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopDelete general exception: " + error }), 401, {'Content-Type': 'application/json'} diff --git a/api/srcv2/api/views/DesktopsPersistentView.py b/api/srcv2/api/views/DesktopsPersistentView.py new file mode 100644 index 000000000..f8bae2278 --- /dev/null +++ b/api/srcv2/api/views/DesktopsPersistentView.py @@ -0,0 +1,216 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log +import traceback + +from uuid import uuid4 +import time,json +import sys,os +from flask import request +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +#from ..libv2.telegram import tsend +def tsend(txt): + None +from ..libv2.carbon import Carbon +carbon = Carbon() + +from ..libv2.quotas import Quotas +quotas = Quotas() + +from ..libv2.api_desktops_persistent import ApiDesktopsPersistent +desktops = ApiDesktopsPersistent() + +@app.route('/api/v2/persistent_desktop', methods=['POST']) +def api_v2_persistent_desktop_new(): + try: + name = request.form.get('name', type = str) + user_id = request.form.get('user_id', type = str) + memory = request.form.get('memory', type = float) + vcpus = request.form.get('vcpus', type = int) + + kind=request.form.get('kind', 'desktop') + template_id = request.form.get('template_id', False) + if template_id == 'False': template_id = False + xml_id = request.form.get('xml_id', False) + xml_definition = request.form.get('xml_definition', False) + disk_size = request.form.get('disk_size', False) + disk_path = request.form.get('disk_path', False) + parent_disk_path = request.form.get('parent_disk_path', False) + iso = request.form.get('iso', False) + boot = request.form.get('template_id', 'disk', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + + if user_id == None or name == None or vcpus == None or memory == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + quotas.DesktopCreate(user_id) + except QuotaUserNewDesktopExceeded: + log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewDesktopExceeded: + log.error("Quota for user "+user_id+" group for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryNewDesktopExceeded: + log.error("Quota for user "+user_id+" category for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"PersistentDesktopNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} + + try: + now=time.time() + #desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,xml_id=xml_id, disk_size=disk_size) + + desktop_id = desktops.New(name, + user_id, + memory, + vcpus, + kind=kind, + from_template_id=template_id, + xml_id=xml_id, + xml_definition=xml_definition, + disk_size=disk_size, + disk_path=disk_path, + parent_disk_path=parent_disk_path, + iso=iso, + boot=boot) + carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) + return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("Desktop for user "+user_id+" from template "+template_id+", user not found") + return json.dumps({"code":1,"msg":"PersistentDesktopNew user not found"}), 404, {'Content-Type': 'application/json'} + except TemplateNotFound: + log.error("Desktop for user "+user_id+" from template "+template_id+" template not found.") + return json.dumps({"code":2,"msg":"PersistentDesktopNew template not found"}), 404, {'Content-Type': 'application/json'} + except DesktopExists: + log.error("Desktop "+name+" for user "+user_id+" already exists") + return json.dumps({"code":3,"msg":"PersistentDesktopNew desktop already exists"}), 404, {'Content-Type': 'application/json'} + except DesktopNotCreated: + log.error("Desktop for user "+user_id+" from template "+template_id+" creation failed.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":4,"msg":"PersistentDesktopNew not created"}), 404, {'Content-Type': 'application/json'} + ### Needs more! + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"PersistentDesktopNew general exception: " + error }), 401, {'Content-Type': 'application/json'} + + + #except DesktopActionTimeout: + # log.error("Desktop delete "+desktop_id+", desktop stop timeout") + # return json.dumps({"code":2,"msg":"Desktop delete stopping timeout"}), 404, {'Content-Type': 'application/json'} + #except DesktopActionFailed: + # log.error("Desktop delete "+desktop_id+", desktop stop failed") + # return json.dumps({"code":3,"msg":"Desktop delete stopping failed"}), 404, {'Content-Type': 'application/json'} + #except DesktopDeleteTimeout: + # log.error("Desktop delete "+desktop_id+", desktop delete timeout") + # return json.dumps({"code":4,"msg":"Desktop delete deleting timeout"}), 404, {'Content-Type': 'application/json'} + +@app.route('/api/v2/desktop/start/', methods=['GET']) +def api_v2_desktop_start(desktop_id=False): + if desktop_id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + user_id=desktops.UserDesktop(desktop_id) + except UserNotFound: + log.error("Desktop user not found") + return json.dumps({"code":1,"msg":"DesktopStart user not found"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopStart general exception: " + error }), 401, {'Content-Type': 'application/json'} + + try: + quotas.DesktopStart(user_id) + except QuotaUserConcurrentExceeded: + log.error("Quota for user "+user_id+" to start a desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopStart user quota CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupConcurrentExceeded: + log.error("Quota for user "+user_id+" to start a desktop in his group is exceeded") + return json.dumps({"code":11,"msg":"DesktopStart user limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryConcurrentExceeded: + log.error("Quota for user "+user_id+" to start a desktop is his category exceeded") + return json.dumps({"code":11,"msg":"DesktopStart user category limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserVcpuExceeded: + log.error("Quota for user "+user_id+" to allocate vCPU is exceeded") + return json.dumps({"code":11,"msg":"DesktopStart user quota vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupVcpuExceeded: + log.error("Quota for user "+user_id+" to allocate vCPU in his group is exceeded") + return json.dumps({"code":11,"msg":"DesktopStart user group limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryVcpuExceeded: + log.error("Quota for user "+user_id+" to allocate vCPU in his category is exceeded") + return json.dumps({"code":11,"msg":"DesktopStart user category limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserMemoryExceeded: + log.error("Quota for user "+user_id+" to allocate MEMORY is exceeded") + return json.dumps({"code":11,"msg":"DesktopStart user quota MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupMemoryExceeded: + log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopStart user group limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryMemoryExceeded: + log.error("Quota for user "+user_id+" category for desktop MEMORY allocation is exceeded") + return json.dumps({"code":11,"msg":"DesktopStart user category limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopStart quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} + + # So now we have checked if desktop exists and if we can create and/or start it + + try: + now=time.time() + desktop_id = desktops.Start(desktop_id) + carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) + return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} + except DesktopActionTimeout: + log.error("Desktop "+desktop_id+" for user "+user_id+" start timeout.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":2,"msg":"DesktopStart start timeout"}), 408, {'Content-Type': 'application/json'} + except DesktopActionFailed: + log.error("Desktop "+desktop_id+" for user "+user_id+" start failed.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":3,"msg":"DesktopStart start failed"}), 500, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopStart general exception: " + error }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v2/desktop/stop/', methods=['GET']) +def api_v2_desktop_stop(desktop_id=False): + if desktop_id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + user_id=desktops.UserDesktop(desktop_id) + except UserNotFound: + log.error("Desktop stop user not found") + return json.dumps({"code":1,"msg":"DesktopStop user not found"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopStop general exception: " + error }), 401, {'Content-Type': 'application/json'} + + try: + desktop_id = desktops.Stop(desktop_id) + return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} + except DesktopActionTimeout: + log.error("Desktop "+desktop_id+" for user "+user_id+" stop timeout.") + return json.dumps({"code":2,"msg":"DesktopStop stop timeout"}), 408, {'Content-Type': 'application/json'} + except DesktopActionFailed: + log.error("Desktop "+desktop_id+" for user "+user_id+" start failed.") + return json.dumps({"code":3,"msg":"DesktopStop stop failed"}), 500, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopStop general exception: " + error }), 401, {'Content-Type': 'application/json'} diff --git a/api/srcv2/api/views/JumperViewerView.py b/api/srcv2/api/views/JumperViewerView.py new file mode 100644 index 000000000..bbaf274a6 --- /dev/null +++ b/api/srcv2/api/views/JumperViewerView.py @@ -0,0 +1,62 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log +import traceback + +from uuid import uuid4 +import time,json +import sys,os +from flask import request +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +from flask import render_template, Response, request, redirect, url_for, send_file, send_from_directory +#from ..libv2.telegram import tsend +def tsend(txt): + None +from ..libv2.carbon import Carbon +carbon = Carbon() + +from ..libv2.quotas import Quotas +quotas = Quotas() + +from ..libv2.api_desktops_common import ApiDesktopsCommon +common = ApiDesktopsCommon() + +@app.route('/vw/img/', methods=['GET']) +def api_v2_img(img): + return send_from_directory('templates/',img) + +@app.route('/vw/', methods=['GET']) +def api_v2_viewer(token): + try: + viewers=common.DesktopViewerFromToken(token) + protocol = request.args.get('protocol', default = False) + return render_template('jumper.html', vmName=viewers['vmName'], vmDescription=viewers['vmDescription'], viewers=json.dumps(viewers)) + #return render_template('jumper.html', data='') + except DesktopNotFound: + log.error("Jumper viewer desktop not found") + return render_template('error.html', error='Incorrect access') + #return json.dumps({"code":1,"msg":"Jumper viewer token not found"}), 404, {'Content-Type': 'application/json'} + except DesktopNotStarted: + log.error("Jumper viewer desktop not started") + return render_template('error.html', error='Desktop could not be started. Try again in a while...') + #return json.dumps({"code":2,"msg":"Jumper viewer desktop is not started"}), 404, {'Content-Type': 'application/json'} + except DesktopActionTimeout: + log.error("Jumper viewer desktop start timeout.") + carbon.send({'create_and_start_time':'100'}) + return render_template('error.html', error='Desktop start timed out. Try again in a while...') + #return json.dumps({"code":2,"msg":"Jumper viewer start timeout"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + log.error("Jumper viewer general exception: "+error) + return render_template('error.html', error='Incorrect access.') + #return json.dumps({"code":9,"msg":"JumperViewer general exception: " + error }), 401, {'Content-Type': 'application/json'} + + diff --git a/api/src/api/views/SundryView.py b/api/srcv2/api/views/SundryView.py similarity index 100% rename from api/src/api/views/SundryView.py rename to api/srcv2/api/views/SundryView.py diff --git a/api/srcv2/api/views/TemplatesView.py b/api/srcv2/api/views/TemplatesView.py new file mode 100644 index 000000000..224d876ff --- /dev/null +++ b/api/srcv2/api/views/TemplatesView.py @@ -0,0 +1,102 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log + +from uuid import uuid4 +import time,json +import sys,os +from flask import request +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +#from ..libv2.telegram import tsend +def tsend(txt): + None +from ..libv2.carbon import Carbon +carbon = Carbon() + +from ..libv2.quotas import Quotas +quotas = Quotas() + +from ..libv2.api_users import ApiUsers +users = ApiUsers() + +# from ..libv2.api_desktops import ApiDesktops +# desktops = ApiDesktops() + +from ..libv2.api_templates import ApiTemplates +templates = ApiTemplates() + +@app.route('/api/v2/template', methods=['POST']) +def api_v2_template_new(): + try: + name = request.form.get('name', type = str) + user_id = request.form.get('user_id', type = str) + desktop_id = request.form.get('desktop_id', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + if user_id == None or name == None or desktop_id == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + quotas.DesktopCreate(user_id) + except QuotaUserNewDesktopExceeded: + log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewDesktopExceeded: + log.error("Quota for user "+user_id+" group for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryNewDesktopExceeded: + log.error("Quota for user "+user_id+" category for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"TemplateNew quota check general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + try: + now=time.time() + #desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,xml_id=xml_id, disk_size=disk_size) + template_id = templates.TemplateNew(name, user_id, desktop_id) + carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) + return json.dumps({'id': template_id}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("Template for user "+user_id+" from desktop "+desktop_id+", user not found") + return json.dumps({"code":1,"msg":"TemplateNew user not found"}), 404, {'Content-Type': 'application/json'} + except TemplateNotFound: + log.error("Template for user "+user_id+" from desktop "+desktop_id+" template not found.") + return json.dumps({"code":2,"msg":"TemplateNew template not found"}), 404, {'Content-Type': 'application/json'} + except DesktopNotCreated: + log.error("Template for user "+user_id+" from desktop "+desktop_id+" creation failed.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":1,"msg":"TemplateNew not created"}), 404, {'Content-Type': 'application/json'} + ### Needs more! + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"TemplateNew general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/template/', methods=['GET']) +def api_v2_template(id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + template = templates.Get(id) + if template: + return json.dumps(template), 200, {'Content-Type': 'application/json'} + return json.dumps({"code":2,"msg":"Template not found"}), 401, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"Template general exception: " + error }), 401, {'Content-Type': 'application/json'} diff --git a/api/srcv2/api/views/UsersView.py b/api/srcv2/api/views/UsersView.py new file mode 100644 index 000000000..582d38be8 --- /dev/null +++ b/api/srcv2/api/views/UsersView.py @@ -0,0 +1,445 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log +import traceback + +from uuid import uuid4 +import time,json +import sys,os +from flask import request, jsonify +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +#from ..libv2.telegram import tsend +def tsend(txt): + None +from ..libv2.carbon import Carbon +carbon = Carbon() + +from ..libv2.quotas import Quotas +quotas = Quotas() + +from ..libv2.api_users import ApiUsers, check_category_domain +users = ApiUsers() + +from ..libv2.isardVpn import isardVpn +vpn = isardVpn() + +@app.route('/api/v2', methods=['GET']) +def api_v2_test(): + return "IsardVDI api v2", 200, {'Content-Type': 'application/json'} + +@app.route('/api/v2/login', methods=['POST']) +def api_v2_login(): + try: + id = request.form.get('id', type = str) + passwd = request.form.get('passwd', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + if id == None or passwd == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + id_ = users.Login(id, passwd) + return jsonify(success=True, id=id_) + except UserLoginFailed: + log.error("User "+id+" login failed.") + return json.dumps({"code":1,"msg":"User login failed"}), 403, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserExists general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/category/', methods=['GET']) +def api_v2_category(id): + try: + data = users.CategoryGet(id) + return json.dumps(data), 200, {'Content-Type': 'application/json'} + except CategoryNotFound: + return json.dumps({"code":1,"msg":"Category "+id+" not exists in database"}), 404, {'Content-Type': 'application/json'} + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"Register general exception: " + error }), 500, {'Content-Type': 'application/json'} + +@app.route('/api/v2/register', methods=['POST']) +def api_v2_register(): + try: + code = request.form.get('code', type = str) + domain = request.form.get("email").split("@")[-1] + except Exception as e: + return ( + json.dumps({"code": 8, "msg": "Incorrect access. exception: " + e}), + 401, + {"Content-Type": "application/json"}, + ) + + try: + data = users.CodeSearch(code) + if check_category_domain(data.get("category"), domain): + return json.dumps(data), 200, {"Content-Type": "application/json"} + else: + log.info(f"Domain {domain} not allowed for category {data.get('category')}") + return ( + json.dumps({"code": 10, "msg": f"User domain {domain} not allowed"}), + 403, + {"Content-Type": "application/json"}, + ) + except CodeNotFound: + log.error("Code not in database.") + return json.dumps({"code":1,"msg":"Code "+code+" not exists in database"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"Register general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/user/', methods=['GET']) +def api_v2_user_exists(id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + user=users.Exists(id) + return json.dumps(user), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"User not exists in database"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserExists general exception: " + error }), 401, {'Content-Type': 'application/json'} + +# Update user name +@app.route('/api/v2/user/', methods=['PUT']) +def api_v2_user_update(id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + name = request.form.get("name", "") + email = request.form.get("email", "") + photo = request.form.get("photo", "") + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + + if name == False and email == False and photo == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query. At least one parameter should be specified." }), 401, {'Content-Type': 'application/json'} + try: + users.Update(id,user_name=name,user_email=email,user_photo=photo) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except UpdateFailed: + log.error("User "+id+" update failed.") + return json.dumps({"code":1,"msg":"User update failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserUpdate general exception: " + error }), 401, {'Content-Type': 'application/json'} + +# Add user +@app.route('/api/v2/user', methods=['POST']) +def api_v2_user_insert(): + try: + # Required + provider = request.form.get('provider', type = str) + user_uid = request.form.get('user_uid', type = str) + user_username = request.form.get('user_username', type = str) + role_id = request.form.get('role', type = str) + category_id = request.form.get('category', type = str) + group_id = request.form.get('group', type = str) + + # Optional + name=request.form.get('name', user_username, type = str) + password = request.form.get('password', False, type = str) + encrypted_password = request.form.get('encrypted_password', False, type = str) + photo = request.form.get('photo', '', type = str) + email = request.form.get('email', '', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + if provider == None or user_username == None or role_id == None or category_id == None or group_id == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + if password == None: password = False + + try: + quotas.UserCreate(category_id,group_id) + except QuotaCategoryNewUserExceeded: + log.error("Quota for creating another user in category "+category_id+" is exceeded") + return json.dumps({"code":11,"msg":"UserNew category quota for adding user exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewUserExceeded: + log.error("Quota for creating another user in group "+group_id+" is exceeded") + return json.dumps({"code":11,"msg":"UserNew group quota for adding user exceeded"}), 507, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} + + + try: + user_id=users.Create( provider, \ + category_id, \ + user_uid, \ + user_username, \ + name, \ + role_id, \ + group_id, \ + password, \ + encrypted_password, \ + photo, \ + email) + return json.dumps({'id':user_id}), 200, {'Content-Type': 'application/json'} + except UserExists: + user_id = provider+'-'+category_id+'-'+user_uid+'-'+user_username + return json.dumps({'id':user_id}), 200, {'Content-Type': 'application/json'} + except RoleNotFound: + log.error("Role "+role_username+" not found.") + return json.dumps({"code":2,"msg":"Role not found"}), 404, {'Content-Type': 'application/json'} + except CategoryNotFound: + log.error("Category "+category_id+" not found.") + return json.dumps({"code":3,"msg":"Category not found"}), 404, {'Content-Type': 'application/json'} + except GroupNotFound: + log.error("Group "+group_id+" not found.") + return json.dumps({"code":4,"msg":"Group not found"}), 404, {'Content-Type': 'application/json'} + except NewUserNotInserted: + log.error("User "+user_username+" could not be inserted into database.") + return json.dumps({"code":5,"msg":"User could not be inserted into database. Already exists!"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserUpdate general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/user/', methods=['DELETE']) +def api_v2_user_delete(user_id): + try: + users.Delete(user_id) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User delete "+user_id+", user not found") + return json.dumps({"code":1,"msg":"User delete id not found"}), 404, {'Content-Type': 'application/json'} + except UserDeleteFailed: + log.error("User delete "+user_id+", user delete failed") + return json.dumps({"code":2,"msg":"User delete failed"}), 404, {'Content-Type': 'application/json'} + except DesktopDeleteFailed: + log.error("User delete for user "+user_id+", desktop delete failed") + return json.dumps({"code":5,"msg":"User delete, desktop deleting failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserDelete general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/user//templates', methods=['GET']) +def api_v2_user_templates(id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + """ try: + quotas.DesktopCreateAndStart(id) + except QuotaUserNewDesktopExceeded: + log.error("Quota for user "+id+" to create a desktop exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user desktop quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewDesktopExceeded: + log.error("Quota for user "+id+" to create a desktop in his group limits is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew group desktop limits CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryNewDesktopExceeded: + log.error("Quota for user "+id+" to create a desktop in his category limits is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew category desktop limits CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserConcurrentExceeded: + log.error("Quota for user "+id+" to start a desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupConcurrentExceeded: + log.error("Quota for user "+id+" to start a desktop in his group is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryConcurrentExceeded: + log.error("Quota for user "+id+" to start a desktop is his category exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category limits CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserVcpuExceeded: + log.error("Quota for user "+id+" to allocate vCPU is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupVcpuExceeded: + log.error("Quota for user "+id+" to allocate vCPU in his group is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user group limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryVcpuExceeded: + log.error("Quota for user "+id+" to allocate vCPU in his category is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category limits vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + + except QuotaUserMemoryExceeded: + log.error("Quota for user "+id+" to allocate MEMORY is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupMemoryExceeded: + log.error("Quota for user "+id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user group limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryMemoryExceeded: + log.error("Quota for user "+id+" category for desktop MEMORY allocation is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category limits MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"DesktopNew quota check general exception: " + error }), 401, {'Content-Type': 'application/json'} """ + + try: + templates = users.Templates(id) + dropdown_templates = [{'id':t['id'],'name':t['name'],'icon':t['icon'],'image':'','description':t['description']} for t in templates] + return json.dumps(dropdown_templates), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"UserTemplates: User not exists in database"}), 404, {'Content-Type': 'application/json'} + except UserTemplatesError: + log.error("Template list for user "+id+" failed.") + return json.dumps({"code":2,"msg":"UserTemplates: list error"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserTemplates general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/user//desktops', methods=['GET']) +def api_v2_user_desktops(id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + desktops = users.Desktops(id) + dropdown_desktops = [ + { + "id": d["id"], + "name": d["name"], + "state": d["status"], + "type": d["type"], + "template": d["from_template"], + "viewers": d["viewers"], + "icon": d["icon"], + "image": d["image"], + "description": d["description"], + "ip": d.get("ip"), + } + for d in desktops + ] + return json.dumps(dropdown_desktops), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"UserDesktops: User not exists in database"}), 404, {'Content-Type': 'application/json'} + except UserDesktopsError: + log.error("Desktops list for user "+id+" failed.") + return json.dumps({"code":2,"msg":"UserDesktops: list error"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"UserDesktops general exception: " + error }), 401, {'Content-Type': 'application/json'} + + +# Add categorygroup +@app.route('/api/v2/category', methods=['POST']) +def api_v2_category_insert(): + try: + # Required + category_name = request.form.get('category_name', type = str) + + # Optional + group_name = request.form.get('group_name', False) + category_limits = request.form.get('category_limits', False) + if category_limits == 'False': category_limits = False + if category_limits != False: category_limits=json.loads(category_limits) + category_quota = request.form.get('category_quota', False) + if category_quota == 'False': category_quota = False + if category_quota != False: category_quota=json.loads(category_quota) + group_quota = request.form.get('group_quota', False) + if group_quota == 'False': group_quota = False + if group_quota != False: group_quota=json.loads(group_quota) + + ## We should check here if limits and quotas have a correct dict schema + + ## + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + if category_name == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + category_id=users.CategoryCreate( category_name, \ + group_name, + category_limits=category_limits, + category_quota=category_quota, + group_quota=group_quota) + return json.dumps({'id':category_id}), 200, {'Content-Type': 'application/json'} + except Exception as e: + log.error("Category create error.") + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"General exception when creating category pair: "+error}), 401, {'Content-Type': 'application/json'} + +# Add group +@app.route('/api/v2/group', methods=['POST']) +def api_v2_group_insert(): + try: + # Required + category_id = request.form.get('category_id', type = str) + group_name = request.form.get('group_name', type = str) + + # Optional + category_limits = request.form.get('category_limits', False) + if category_limits == 'False': category_limits = False + if category_limits != False: category_limits=json.loads(category_limits) + category_quota = request.form.get('category_quota', False) + if category_quota == 'False': category_quota = False + if category_quota != False: category_quota=json.loads(category_quota) + group_quota = request.form.get('group_quota', False) + if group_quota == 'False': group_quota = False + if group_quota != False: group_quota=json.loads(group_quota) + + ## We should check here if limits and quotas have a correct dict schema + + ## + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + error }), 401, {'Content-Type': 'application/json'} + if category_id == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + group_id=users.GroupCreate( category_id, \ + group_name, + category_limits=category_limits, + category_quota=category_quota, + group_quota=group_quota) + return json.dumps({'id':group_id}), 200, {'Content-Type': 'application/json'} + except Exception as e: + log.error(" Group create error.") + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"General exception when creating group: "+error}), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v2/categories', methods=['GET']) +def api_v2_categories(): + try: + return json.dumps(users.CategoriesGet()), 200, {'Content-Type': 'application/json'} + except Exception as e: + error = traceback.format_exc() + return json.dumps({"code":9,"msg":"CategoriesGet general exception: " + error }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/user//vpn//', methods=['GET']) +@app.route('/api/v2/user//vpn/', methods=['GET']) +# kind = config,install +# os = +def api_v2_user_vpn(id, kind, os=False): + if not os and kind != "config": + return ( + json.dumps({"code": 9, "msg": "UserVpn: no OS supplied"}), + 401, + {"Content-Type": "application/json"}, + ) + + vpn_data = vpn.vpn_data("users", kind, os, id) + + if vpn_data: + return json.dumps(vpn_data), 200, {"Content-Type": "application/json"} + else: + return ( + json.dumps({"code": 9, "msg": "UserVpn no VPN data"}), + 401, + {"Content-Type": "application/json"}, + ) diff --git a/api/srcv2/api/views/XmlView.py b/api/srcv2/api/views/XmlView.py new file mode 100644 index 000000000..d218f5ed2 --- /dev/null +++ b/api/srcv2/api/views/XmlView.py @@ -0,0 +1,58 @@ +#!flask/bin/python +# coding=utf-8 +# +# Copyright 2017-2020 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +from api import app +import traceback + +from uuid import uuid4 +import json +from flask import request +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + + +def tsend(txt): + None + + +from ..libv2.carbon import Carbon + +carbon = Carbon() + +from ..libv2.quotas import Quotas + +quotas = Quotas() + +from ..libv2.api_xml import ApiXml + +xml = ApiXml() + + +@app.route("/api/v2/xml/virt_install/", methods=["GET"]) +def api_v2_xml_virt_install(id): + try: + data = xml.VirtInstallGet(id) + return json.dumps(data), 200, {"Content-Type": "application/json"} + except XmlNotFound: + return ( + json.dumps( + {"code": 1, "msg": "VirtInstall " + id + " not exists in database"} + ), + 404, + {"Content-Type": "application/json"}, + ) + + except Exception: + error = traceback.format_exc() + return ( + json.dumps( + {"code": 9, "msg": "VirtInstallGet general exception: " + error} + ), + 500, + {"Content-Type": "application/json"}, + ) diff --git a/api/srcv2/api/views/__ApiViews.py b/api/srcv2/api/views/__ApiViews.py new file mode 100644 index 000000000..f22cc4e09 --- /dev/null +++ b/api/srcv2/api/views/__ApiViews.py @@ -0,0 +1,504 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria Viñolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +#!flask/bin/python +# coding=utf-8 +from api import app +import logging as log + +from uuid import uuid4 +import time,json +import sys,os +from flask import request +from ..libv2.apiv2_exc import * +from ..libv2.quotas_exc import * + +#from ..libv2.telegram import tsend +def tsend(txt): + None +from ..libv2.carbon import Carbon +carbon = Carbon() + +from ..libv2.quotas import Quotas +quotas = Quotas() + +@app.route('/api/v2/category/', methods=['GET']) +def api_v2_category(id): + try: + data = app.lib.CategoryGet(id) + return json.dumps(data), 200, {'Content-Type': 'application/json'} + except CategoryNotFound: + return json.dumps({"code":1,"msg":"Category "+id+" not exists in database"}), 404, {'Content-Type': 'application/json'} + + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"Register general exception: " + str(e) }), 500, {'Content-Type': 'application/json'} + +@app.route('/api/v2/register', methods=['POST']) +def api_v2_register(): + try: + code = request.form.get('code', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + try: + data = app.lib.CodeSearch(code) + return json.dumps(data), 200, {'Content-Type': 'application/json'} + except CodeNotFound: + log.error("Code not in database.") + return json.dumps({"code":1,"msg":"Code "+code+" not exists in database"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"Register general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v2/user/', methods=['GET']) +def api_v2_user_exists(id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + app.lib.UserExists(id) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"User not exists in database"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"UserExists general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + +# Update user name +@app.route('/api/v2/user/', methods=['PUT']) +def api_v2_user_update(id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + name = request.form.get('name', type = str) + email = request.form.get('email', type = str) + photo = request.form.get('photo', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + if photo == None: + photo = "" + + if name == None or email == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + try: + app.lib.UserUpdate(id,name,email,photo) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except UpdateFailed: + log.error("User "+id+" update failed.") + return json.dumps({"code":1,"msg":"User update failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"UserUpdate general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + +# Add user +@app.route('/api/v2/user', methods=['POST']) +def api_v2_user_insert(): + try: + provider = request.form.get('provider', type = str) + user_uid = request.form.get('user_uid', type = str) + user_username = request.form.get('user_username', type = str) + role_id = request.form.get('role', type = str) + category_id = request.form.get('category', type = str) + group_id = request.form.get('group', type = str) + password = request.form.get('password', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + if user_username == None or role_id == None or category_id == None or group_id == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + if password == None: password = False + + try: + quotas.UserCreate(category_id,group_id) + except QuotaCategoryNewUserExceeded: + log.error("Quota for creating another user in category "+category_id+" is exceeded") + return json.dumps({"code":11,"msg":"UserNew category quota for adding user exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewUserExceeded: + log.error("Quota for creating another user in group "+group_id+" is exceeded") + return json.dumps({"code":11,"msg":"UserNew group quota for adding user exceeded"}), 507, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"UserNew quota check general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + + try: + user_id=app.lib.UserCreate(user_uid,user_username,provider,role_id,category_id,group_id,password) + return json.dumps({'id':user_id}), 200, {'Content-Type': 'application/json'} + except UserExists: + log.error("User "+user_username+" already exists.") + return json.dumps({"code":1,"msg":"User already exists"}), 404, {'Content-Type': 'application/json'} + except RoleNotFound: + log.error("Role "+role_username+" not found.") + return json.dumps({"code":2,"msg":"Role not found"}), 404, {'Content-Type': 'application/json'} + except CategoryNotFound: + log.error("Category "+category_id+" not found.") + return json.dumps({"code":3,"msg":"Category not found"}), 404, {'Content-Type': 'application/json'} + except GroupNotFound: + log.error("Group "+group_id+" not found.") + return json.dumps({"code":4,"msg":"Group not found"}), 404, {'Content-Type': 'application/json'} + except NewUserNotInserted: + log.error("User "+user_username+" could not be inserted into database.") + return json.dumps({"code":5,"msg":"User could not be inserted into database. Already exists!"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"UserUpdate general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/user/', methods=['DELETE']) +def api_v2_user_delete(user_id): + try: + app.lib.UserDelete(user_id) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User delete "+user_id+", user not found") + return json.dumps({"code":1,"msg":"User delete id not found"}), 404, {'Content-Type': 'application/json'} + except UserDeleteFailed: + log.error("User delete "+user_id+", user delete failed") + return json.dumps({"code":2,"msg":"User delete failed"}), 404, {'Content-Type': 'application/json'} + except DesktopDeleteFailed: + log.error("User delete for user "+user_id+", desktop delete failed") + return json.dumps({"code":5,"msg":"User delete, desktop deleting failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"UserDelete general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + + +@app.route('/api/v2/user//templates', methods=['GET']) +def api_v2_user_templates(id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + templates = app.lib.UserTemplates(id) + dropdown_templates = [{'id':t['id'],'name':t['name'],'icon':t['icon'],'description':t['description']} for t in templates] + return json.dumps(dropdown_templates), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"UserTemplates: User not exists in database"}), 404, {'Content-Type': 'application/json'} + except UserTemplatesError: + log.error("Template list for user "+id+" failed.") + return json.dumps({"code":2,"msg":"UserTemplates: list error"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"UserTemplates general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/user//desktops', methods=['GET']) +def api_v2_user_desktops(id=False): + if id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + desktops = app.lib.UserDesktops(id) + dropdown_desktops = [{'id':d['id'],'name':d['name'],'status':d['status'],'icon':d['icon'],'description':d['description']} for d in desktops] + return json.dumps(dropdown_desktops), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("User "+id+" not in database.") + return json.dumps({"code":1,"msg":"UserDesktops: User not exists in database"}), 404, {'Content-Type': 'application/json'} + except UserDesktopsError: + log.error("Template list for user "+id+" failed.") + return json.dumps({"code":2,"msg":"UserDesktops: list error"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"UserDesktops general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v2/login', methods=['POST']) +def api_v2_login(): + try: + id = request.form.get('id', type = str) + passwd = request.form.get('passwd', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + if id == None or passwd == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + app.lib.Login(id,passwd) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except UserLoginFailed: + log.error("User "+id+" login failed.") + return json.dumps({"code":1,"msg":"User login failed"}), 403, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"UserExists general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v2/desktop', methods=['POST']) +def api_v2_desktop_new(): + try: + user_id = request.form.get('id', type = str) + template = request.form.get('template', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + if user_id == None or template == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + quotas.DesktopCreateAndStart(user_id) + except QuotaUserConcurrentExceeded: + log.error("Quota for user "+user_id+" for starting another desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user quota CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryConcurrentExceeded: + log.error("Quota for user "+user_id+" category for starting another desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category quota CONCURRENT exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryVcpuExceeded: + log.error("Quota for user "+user_id+" category for desktop vCPU allocation is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category quota vCPU allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryMemoryExceeded: + log.error("Quota for user "+user_id+" category for desktop MEMORY allocation is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category quota MEMORY allocation exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaUserNewDesktopExceeded: + log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewDesktopExceeded: + log.error("Quota for user "+user_id+" group for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryNewDesktopExceeded: + log.error("Quota for user "+user_id+" category for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"DesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"DesktopNew quota check general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + try: + now=time.time() + desktop_id = app.lib.DesktopNewNonpersistent(user_id,template) + carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) + return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("Desktop for user "+user_id+" from template "+template+", user not found") + return json.dumps({"code":1,"msg":"DesktopNew user not found"}), 404, {'Content-Type': 'application/json'} + except TemplateNotFound: + log.error("Desktop for user "+user_id+" from template "+template+" template not found.") + return json.dumps({"code":2,"msg":"DesktopNew template not found"}), 404, {'Content-Type': 'application/json'} + except DesktopNotCreated: + log.error("Desktop for user "+user_id+" from template "+template+" creation failed.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":1,"msg":"DesktopNew not created"}), 404, {'Content-Type': 'application/json'} + except DesktopActionTimeout: + log.error("Desktop for user "+user_id+" from template "+template+" start timeout.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":2,"msg":"DesktopNew start timeout"}), 404, {'Content-Type': 'application/json'} + except DesktopActionFailed: + log.error("Desktop for user "+user_id+" from template "+template+" start failed.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":3,"msg":"DesktopNew start failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"DesktopNew general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v2/desktop//viewer/', methods=['GET']) +def api_v2_desktop_viewer(desktop_id=False, protocol=False): + if desktop_id == False or protocol == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + viewer = app.lib.DesktopViewer(desktop_id,protocol) + return json.dumps({'viewer': viewer}), 200, {'Content-Type': 'application/json'} + except DesktopNotFound: + log.error("Viewer for desktop "+desktop_id+" with protocol "+protocol+", desktop not found") + return json.dumps({"code":1,"msg":"Desktop viewer id not found"}), 404, {'Content-Type': 'application/json'} + except DesktopNotStarted: + log.error("Viewer for desktop "+desktop_id+" with protocol "+protocol+", desktop not started") + return json.dumps({"code":2,"msg":"Desktop viewer is not started"}), 404, {'Content-Type': 'application/json'} + except NotAllowed: + log.error("Viewer for desktop "+desktop_id+" with protocol "+protocol+", viewer access not allowed") + return json.dumps({"code":3,"msg":"Desktop viewer id not owned by user"}), 404, {'Content-Type': 'application/json'} + except ViewerProtocolNotFound: + log.error("Viewer for desktop "+desktop_id+" with protocol "+protocol+", viewer protocol not found") + return json.dumps({"code":4,"msg":"Desktop viewer protocol not found"}), 404, {'Content-Type': 'application/json'} + except ViewerProtocolNotImplemented: + log.error("Viewer for desktop "+desktop_id+" with protocol "+protocol+", viewer protocol not implemented") + return json.dumps({"code":5,"msg":"Desktop viewer protocol not implemented"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"DesktopViewer general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/desktop/', methods=['DELETE']) +def api_v2_desktop_delete(desktop_id=False): + if desktop_id == False: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + now=time.time() + app.lib.DesktopDelete(desktop_id) + carbon.send({'delete_time':str(round(time.time()-now,2))}) + return json.dumps({}), 200, {'Content-Type': 'application/json'} + except DesktopNotFound: + log.error("Desktop delete "+desktop_id+", desktop not found") + return json.dumps({"code":1,"msg":"Desktop delete id not found"}), 404, {'Content-Type': 'application/json'} + except DesktopDeleteFailed: + log.error("Desktop delete "+desktop_id+", desktop delete failed") + return json.dumps({"code":5,"msg":"Desktop delete deleting failed"}), 404, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"DesktopDelete general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + +@app.route('/api/v2/persistent_desktop', methods=['POST']) +def api_v2_persistent_desktop_new(): + try: + name = request.form.get('name', type = str) + user_id = request.form.get('user_id', type = str) + memory = request.form.get('memory', type = float) + vcpus = request.form.get('vcpus', type = int) + + template_id = request.form.get('template_id', type = str) + xml_id = request.form.get('xml_id', type = str) + + disk_size = request.form.get('disk_size', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + if user_id == None or name == None or vcpus == None or memory == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + quotas.DesktopCreate(user_id) + except QuotaUserNewDesktopExceeded: + log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewDesktopExceeded: + log.error("Quota for user "+user_id+" group for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryNewDesktopExceeded: + log.error("Quota for user "+user_id+" category for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"PersistentDesktopNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"PersistentDesktopNew quota check general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + try: + now=time.time() + #desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,xml_id=xml_id, disk_size=disk_size) + desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,from_template_id=template_id, disk_size=disk_size) + carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) + return json.dumps({'id': desktop_id}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("Desktop for user "+user_id+" from template "+template_id+", user not found") + return json.dumps({"code":1,"msg":"PersistentDesktopNew user not found"}), 404, {'Content-Type': 'application/json'} + except TemplateNotFound: + log.error("Desktop for user "+user_id+" from template "+template_id+" template not found.") + return json.dumps({"code":2,"msg":"PersistentDesktopNew template not found"}), 404, {'Content-Type': 'application/json'} + except DesktopNotCreated: + log.error("Desktop for user "+user_id+" from template "+template_id+" creation failed.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":1,"msg":"PersistentDesktopNew not created"}), 404, {'Content-Type': 'application/json'} + ### Needs more! + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"PersistentDesktopNew general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + +@app.route('/api/v2/template', methods=['POST']) +def api_v2_template_new(): + try: + name = request.form.get('name', type = str) + user_id = request.form.get('user_id', type = str) + desktop_id = request.form.get('desktop_id', type = str) + except Exception as e: + return json.dumps({"code":8,"msg":"Incorrect access. exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + if user_id == None or name == None or desktop_id == None: + log.error("Incorrect access parameters. Check your query.") + return json.dumps({"code":8,"msg":"Incorrect access parameters. Check your query." }), 401, {'Content-Type': 'application/json'} + + try: + quotas.DesktopCreate(user_id) + except QuotaUserNewDesktopExceeded: + log.error("Quota for user "+user_id+" for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaGroupNewDesktopExceeded: + log.error("Quota for user "+user_id+" group for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except QuotaCategoryNewDesktopExceeded: + log.error("Quota for user "+user_id+" category for creating another desktop is exceeded") + return json.dumps({"code":11,"msg":"TemplateNew user category quota CREATE exceeded"}), 507, {'Content-Type': 'application/json'} + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"TemplateNew quota check general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + try: + now=time.time() + #desktop_id = app.lib.DesktopNewPersistent(name, user_id,memory,vcpus,xml_id=xml_id, disk_size=disk_size) + template_id = app.lib.TemplateNew(name, user_id, desktop_id) + carbon.send({'create_and_start_time':str(round(time.time()-now,2))}) + return json.dumps({'id': template_id}), 200, {'Content-Type': 'application/json'} + except UserNotFound: + log.error("Template for user "+user_id+" from desktop "+desktop_id+", user not found") + return json.dumps({"code":1,"msg":"TemplateNew user not found"}), 404, {'Content-Type': 'application/json'} + except TemplateNotFound: + log.error("Template for user "+user_id+" from desktop "+desktop_id+" template not found.") + return json.dumps({"code":2,"msg":"TemplateNew template not found"}), 404, {'Content-Type': 'application/json'} + except DesktopNotCreated: + log.error("Template for user "+user_id+" from desktop "+desktop_id+" creation failed.") + carbon.send({'create_and_start_time':'100'}) + return json.dumps({"code":1,"msg":"TemplateNew not created"}), 404, {'Content-Type': 'application/json'} + ### Needs more! + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + log.error(str(exc_type), str(fname), str(exc_tb.tb_lineno)) + return json.dumps({"code":9,"msg":"TemplateNew general exception: " + str(e) }), 401, {'Content-Type': 'application/json'} + + + #except DesktopActionTimeout: + # log.error("Desktop delete "+desktop_id+", desktop stop timeout") + # return json.dumps({"code":2,"msg":"Desktop delete stopping timeout"}), 404, {'Content-Type': 'application/json'} + #except DesktopActionFailed: + # log.error("Desktop delete "+desktop_id+", desktop stop failed") + # return json.dumps({"code":3,"msg":"Desktop delete stopping failed"}), 404, {'Content-Type': 'application/json'} + #except DesktopDeleteTimeout: + # log.error("Desktop delete "+desktop_id+", desktop delete timeout") + # return json.dumps({"code":4,"msg":"Desktop delete deleting timeout"}), 404, {'Content-Type': 'application/json'} diff --git a/api/srcv2/api/views/__init__.py b/api/srcv2/api/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/src/api_client_test.py b/api/srcv2/api_client_test.py similarity index 100% rename from api/src/api_client_test.py rename to api/srcv2/api_client_test.py diff --git a/api/src/start.py b/api/srcv2/start.py similarity index 100% rename from api/src/start.py rename to api/srcv2/start.py diff --git a/authentication/authentication/authentication.go b/authentication/authentication/authentication.go new file mode 100644 index 000000000..d2e9563f6 --- /dev/null +++ b/authentication/authentication/authentication.go @@ -0,0 +1,298 @@ +package authentication + +import ( + "context" + "errors" + "fmt" + "time" + + "gitlab.com/isard/isardvdi/authentication/authentication/provider" + "gitlab.com/isard/isardvdi/authentication/cfg" + "gitlab.com/isard/isardvdi/authentication/model" + + "github.com/golang-jwt/jwt" + r "gopkg.in/rethinkdb/rethinkdb-go.v6" +) + +type Interface interface { + Providers() []string + Provider(provider string) provider.Provider + Login(ctx context.Context, provider string, categoryID string, args map[string]string) (tkn, redirect string, err error) + Callback(ctx context.Context, args map[string]string) (tkn, redirect string, err error) + Check(ctx context.Context, tkn string) error + // Refresh() + // Register() +} + +type Authentication struct { + Secret string + DB r.QueryExecutor + providers map[string]provider.Provider +} + +func Init(cfg cfg.Authentication, db r.QueryExecutor) *Authentication { + providers := map[string]provider.Provider{ + "unknown": &provider.Unknown{}, + } + + if cfg.Local { + local := provider.InitLocal(db) + providers[local.String()] = local + } + + if cfg.Google.ClientID != "" && cfg.Google.ClientSecret != "" { + google := provider.InitGoogle(cfg) + providers[google.String()] = google + } + + return &Authentication{ + Secret: cfg.Secret, + DB: db, + providers: providers, + } +} + +func (a *Authentication) Providers() []string { + providers := []string{} + for k := range a.providers { + if k == provider.UnknownString || k == provider.LocalString { + continue + } + + providers = append(providers, k) + } + + return providers +} + +func (a *Authentication) Provider(p string) provider.Provider { + prv := a.providers[p] + if prv == nil { + return a.providers[provider.UnknownString] + } + + return prv +} + +type Claims struct { + *jwt.StandardClaims + KeyID string `json:"kid"` + Data ClaimsData `json:"data"` +} + +type ClaimsData struct { + Provider string `json:"provider"` + ID string `json:"user_id"` + RoleID string `json:"role_id"` + CategoryID string `json:"category_id"` + GroupID string `json:"group_id"` +} + +func (a *Authentication) signToken(u *model.User) (string, error) { + tkn := jwt.NewWithClaims(jwt.SigningMethodHS256, &Claims{ + &jwt.StandardClaims{ + Issuer: "isard-authentication", + ExpiresAt: time.Now().Add(4 * time.Hour).Unix(), + }, + // TODO: Other signing keys + "isardvdi", + ClaimsData{ + u.Provider, + u.ID(), + u.Role, + u.Category, + u.Group, + }, + }) + + ss, err := tkn.SignedString([]byte(a.Secret)) + if err != nil { + return "", fmt.Errorf("sign the token: %w", err) + } + + return ss, nil +} + +func (a *Authentication) parseToken(ss string, claims jwt.Claims) (*jwt.Token, error) { + tkn, err := jwt.ParseWithClaims(ss, claims, func(tkn *jwt.Token) (interface{}, error) { + if _, ok := tkn.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", tkn.Header["alg"]) + } + + return []byte(a.Secret), nil + }) + if err != nil { + return nil, fmt.Errorf("error parsing the JWT token: %w", err) + } + + if !tkn.Valid { + return nil, errors.New("invalid JWT token") + } + + return tkn, nil +} + +type RegisterClaims struct { + *jwt.StandardClaims + KeyID string `json:"kid"` + Type string `json:"type"` + Provider string `json:"provider"` + UserID string `json:"user_id"` + Username string `json:"username"` + CategoryID string `json:"category_id"` + Name string `json:"name"` + Email string `json:"email"` + Photo string `json:"photo"` +} + +const claimsRegisterType = "register" + +func (a *Authentication) signRegister(u *model.User) (string, error) { + tkn := jwt.NewWithClaims(jwt.SigningMethodHS256, &RegisterClaims{ + &jwt.StandardClaims{ + Issuer: "isard-authentication", + ExpiresAt: time.Now().Add(4 * time.Hour).Unix(), + }, + // TODO: Other signing keys + "isardvdi", + claimsRegisterType, + u.Provider, + u.UID, + u.Username, + u.Category, + u.Name, + u.Email, + u.Photo, + }) + + ss, err := tkn.SignedString([]byte(a.Secret)) + if err != nil { + return "", fmt.Errorf("sign the register token: %w", err) + } + + return ss, nil +} + +func (a *Authentication) Login(ctx context.Context, prv, categoryID string, args map[string]string) (string, string, error) { + var u *model.User + var redirect string + var err error + + // Handle the register flow + if args[provider.TokenArgsKey] != "" { + tkn, err := a.parseToken(args[provider.TokenArgsKey], &RegisterClaims{}) + if err != nil { + return "", "", fmt.Errorf("parse register token: %w", err) + } + + register, ok := tkn.Claims.(*RegisterClaims) + if !ok { + return "", "", errors.New("invalid register token") + } + + u = &model.User{ + Provider: register.Provider, + Category: register.CategoryID, + UID: register.UserID, + Username: register.Username, + } + if err := u.Load(ctx, a.DB); err != nil { + if errors.Is(err, model.ErrNotFound) { + return "", "", errors.New("user not registered") + } + + return "", "", fmt.Errorf("load user from db: %w", err) + } + + // Normal login flow + } else { + p := a.Provider(prv) + u, redirect, err = p.Login(ctx, categoryID, args) + if err != nil { + return "", "", fmt.Errorf("login: %w", err) + } + + if redirect != "" { + return "", redirect, nil + } + + exists, err := u.Exists(ctx, a.DB) + if err != nil { + return "", "", fmt.Errorf("check if user exists: %w", err) + } + + if !exists { + // If the user has logged in correctly, but doesn't exist in the DB, they have to register first! + ss, err := a.signRegister(u) + return ss, "", err + } + } + + ss, err := a.signToken(u) + if err != nil { + return "", "", err + } + + return ss, redirect, nil +} + +func (a *Authentication) Callback(ctx context.Context, args map[string]string) (string, string, error) { + ss := args["state"] + if ss == "" { + return "", "", errors.New("callback state not provided") + } + + tkn, err := a.parseToken(ss, &provider.CallbackClaims{}) + if err != nil { + return "", "", fmt.Errorf("parse callback state: %w", err) + } + + claims, ok := tkn.Claims.(*provider.CallbackClaims) + if !ok { + return "", "", errors.New("unknown callback state claims format") + } + + p := a.Provider(claims.Provider) + + u, redirect, err := p.Callback(ctx, claims, args) + if err != nil { + return "", "", fmt.Errorf("callback: %w", err) + } + + exists, err := u.Exists(ctx, a.DB) + if err != nil { + return "", "", fmt.Errorf("check if user exists: %w", err) + } + + if exists { + ss, err = a.signToken(u) + if err != nil { + return "", "", err + } + } else { + ss, err = a.signRegister(u) + if err != nil { + return "", "", err + } + } + + if redirect == "" { + redirect = claims.Redirect + } + + return ss, redirect, nil +} + +func (a *Authentication) Check(ctx context.Context, ss string) error { + tkn, err := a.parseToken(ss, &Claims{}) + if err != nil { + return err + } + + _, ok := tkn.Claims.(*Claims) + if !ok { + return errors.New("unknown JWT claims format") + } + + return nil +} diff --git a/authentication/authentication/provider/google.go b/authentication/authentication/provider/google.go new file mode 100644 index 000000000..cfc769535 --- /dev/null +++ b/authentication/authentication/provider/google.go @@ -0,0 +1,107 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "gitlab.com/isard/isardvdi/authentication/cfg" + "gitlab.com/isard/isardvdi/authentication/model" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +const GoogleString = "google" + +type Google struct { + provider *oauth2Provider +} + +func InitGoogle(cfg cfg.Authentication) *Google { + return &Google{ + &oauth2Provider{ + GoogleString, + cfg.Secret, + &oauth2.Config{ + ClientID: cfg.Google.ClientID, + ClientSecret: cfg.Google.ClientSecret, + Scopes: []string{ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + }, + Endpoint: google.Endpoint, + RedirectURL: fmt.Sprintf("https://%s/authentication/callback", cfg.Host), + }, + }, + } +} + +func (g *Google) Login(ctx context.Context, categoryID string, args map[string]string) (*model.User, string, error) { + redirect := args["redirect"] + redirect, err := g.provider.login(categoryID, redirect) + if err != nil { + return nil, "", err + } + + return nil, redirect, nil +} + +type googleAPIUsr struct { + UID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Photo string `json:"picture,omitempty"` +} + +func (g *Google) Callback(ctx context.Context, claims *CallbackClaims, args map[string]string) (*model.User, string, error) { + oTkn, err := g.provider.callback(ctx, args) + if err != nil { + return nil, "", err + } + + q := url.Values{"access_token": {oTkn}} + url := url.URL{ + Scheme: "https", + Host: "www.googleapis.com", + Path: "oauth2/v2/userinfo", + RawQuery: q.Encode(), + } + + rsp, err := http.Get(url.String()) + if err != nil { + return nil, "", fmt.Errorf("call Google API: %w", err) + } + defer rsp.Body.Close() + + if rsp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(rsp.Body) + + return nil, "", fmt.Errorf("call Google API: HTTP Code %d: %s", rsp.StatusCode, b) + } + + gUsr := &googleAPIUsr{} + if err := json.NewDecoder(rsp.Body).Decode(&gUsr); err != nil { + return nil, "", fmt.Errorf("unmarshal Google API json response: %w", err) + } + + u := &model.User{ + UID: gUsr.UID, + Username: strings.Split(gUsr.Email, "@")[0], + Provider: claims.Provider, + Category: claims.CategoryID, + Name: gUsr.Name, + Email: gUsr.Email, + Photo: gUsr.Photo, + } + + return u, "", nil +} + +func (g *Google) String() string { + return GoogleString +} diff --git a/authentication/authentication/provider/local.go b/authentication/authentication/provider/local.go new file mode 100644 index 000000000..807796f1b --- /dev/null +++ b/authentication/authentication/provider/local.go @@ -0,0 +1,89 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "gitlab.com/isard/isardvdi/authentication/model" + + "golang.org/x/crypto/bcrypt" + r "gopkg.in/rethinkdb/rethinkdb-go.v6" +) + +const LocalString = "local" + +type Local struct { + db r.QueryExecutor +} + +func InitLocal(db r.QueryExecutor) *Local { + return &Local{db} +} + +type localArgs struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + +func parseLocalArgs(args map[string]string) (string, string, error) { + username := args["username"] + password := args["password"] + + creds := &localArgs{} + if body, ok := args[RequestBodyArgsKey]; ok && body != "" { + if err := json.Unmarshal([]byte(body), creds); err != nil { + return "", "", fmt.Errorf("unmarshal local authentication request body: %w", err) + } + } + + if username == "" { + if creds.Username == "" { + return "", "", errors.New("username not provided") + } + + username = creds.Username + } + + if password == "" { + if creds.Password == "" { + return "", "", errors.New("password not provided") + } + + password = creds.Password + } + + return username, password, nil +} + +func (l *Local) Login(ctx context.Context, categoryID string, args map[string]string) (*model.User, string, error) { + usr, pwd, err := parseLocalArgs(args) + if err != nil { + return nil, "", err + } + + u := &model.User{ + UID: usr, + Username: usr, + Provider: LocalString, + Category: categoryID, + } + if err := u.Load(ctx, l.db); err != nil { + return nil, "", fmt.Errorf("load user from DB: %w", err) + } + + if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(pwd)); err != nil { + return nil, "", ErrInvalidCredentials + } + + return u, args["redirect"], nil +} + +func (l *Local) Callback(context.Context, *CallbackClaims, map[string]string) (*model.User, string, error) { + return nil, "", errInvalidIDP +} + +func (l *Local) String() string { + return LocalString +} diff --git a/authentication/authentication/provider/oauth2.go b/authentication/authentication/provider/oauth2.go new file mode 100644 index 000000000..ea5b14ce9 --- /dev/null +++ b/authentication/authentication/provider/oauth2.go @@ -0,0 +1,44 @@ +package provider + +import ( + "context" + "fmt" + "time" + + "github.com/golang-jwt/jwt" + "golang.org/x/oauth2" +) + +type oauth2Provider struct { + provider string + secret string + cfg *oauth2.Config +} + +func (o *oauth2Provider) login(categoryID, redirect string) (string, error) { + tkn := jwt.NewWithClaims(jwt.SigningMethodHS256, &CallbackClaims{ + &jwt.StandardClaims{ + Issuer: "isard-authentication", + ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), + }, + o.provider, + categoryID, + redirect, + }) + + ss, err := tkn.SignedString([]byte(o.secret)) + if err != nil { + return "", fmt.Errorf("sign the token: %w", err) + } + + return o.cfg.AuthCodeURL(ss), nil +} + +func (o *oauth2Provider) callback(ctx context.Context, args map[string]string) (string, error) { + tkn, err := o.cfg.Exchange(ctx, args["code"]) + if err != nil { + return "", fmt.Errorf("exchange oauth2 token: %w", err) + } + + return tkn.AccessToken, nil +} diff --git a/authentication/authentication/provider/provider.go b/authentication/authentication/provider/provider.go new file mode 100644 index 000000000..92d74adab --- /dev/null +++ b/authentication/authentication/provider/provider.go @@ -0,0 +1,51 @@ +package provider + +import ( + "context" + "errors" + + "gitlab.com/isard/isardvdi/authentication/model" + + "github.com/golang-jwt/jwt" +) + +const ( + TokenArgsKey = "token" + ProviderArgsKey = "provider" + CategoryIDArgsKey = "category_id" + RequestBodyArgsKey = "request_body" +) + +var ErrInvalidCredentials = errors.New("invalid credentials") + +type CallbackClaims struct { + *jwt.StandardClaims + Provider string `json:"provider"` + CategoryID string `json:"category_id"` + Redirect string `json:"redirect"` +} + +type Provider interface { + Login(ctx context.Context, categoryID string, args map[string]string) (u *model.User, redirect string, err error) + Callback(ctx context.Context, claims *CallbackClaims, args map[string]string) (u *model.User, redirect string, err error) + String() string +} + +var errUnknownIDP = errors.New("unknown identity provider") +var errInvalidIDP = errors.New("invalid identity provider for this operation") + +const UnknownString = "unknown" + +type Unknown struct{} + +func (Unknown) String() string { + return UnknownString +} + +func (Unknown) Login(context.Context, string, map[string]string) (*model.User, string, error) { + return nil, "", errUnknownIDP +} + +func (Unknown) Callback(context.Context, *CallbackClaims, map[string]string) (*model.User, string, error) { + return nil, "", errUnknownIDP +} diff --git a/authentication/authentication/testing.go b/authentication/authentication/testing.go new file mode 100644 index 000000000..05c380e57 --- /dev/null +++ b/authentication/authentication/testing.go @@ -0,0 +1,36 @@ +package authentication + +import ( + "context" + + "github.com/stretchr/testify/mock" + "gitlab.com/isard/isardvdi/authentication/authentication/provider" +) + +type AuthenticationMock struct { + mock.Mock +} + +func (m *AuthenticationMock) Login(ctx context.Context, provider string, categoryID string, args map[string]string) (string, string, error) { + mArgs := m.Called(ctx, provider, categoryID, args) + return mArgs.String(0), mArgs.String(1), mArgs.Error(2) +} + +func (m *AuthenticationMock) Callback(ctx context.Context, args map[string]string) (string, string, error) { + mArgs := m.Called(ctx, args) + return mArgs.String(0), mArgs.String(1), mArgs.Error(2) +} + +func (m *AuthenticationMock) Check(ctx context.Context, tkn string) error { + mArgs := m.Called(ctx, tkn) + return mArgs.Error(0) +} + +func (m *AuthenticationMock) Providers() []string { + return []string{"local", "google"} +} + +func (m *AuthenticationMock) Provider(prv string) provider.Provider { + mArgs := m.Called(prv) + return mArgs.Get(0).(provider.Provider) +} diff --git a/authentication/build/package/Dockerfile b/authentication/build/package/Dockerfile new file mode 100644 index 000000000..4ce2345ea --- /dev/null +++ b/authentication/build/package/Dockerfile @@ -0,0 +1,36 @@ +# +# Build phase +# +FROM golang:1.16-alpine as build + +RUN apk add --no-cache \ + git + +WORKDIR /build + +COPY go.mod /build +COPY go.sum /build + +RUN go mod download + +WORKDIR / + +COPY pkg /build/pkg +COPY authentication /build/authentication + +WORKDIR /build/authentication + +RUN CGO_ENABLED=0 go build -o bin/authentication cmd/authentication/main.go + + +# +# Authentication +# +FROM alpine + +RUN apk add --no-cache \ + ca-certificates + +COPY --from=build /build/authentication/bin/authentication /authentication + +CMD [ "/authentication" ] diff --git a/authentication/cfg/cfg.go b/authentication/cfg/cfg.go new file mode 100644 index 000000000..a7ae0c087 --- /dev/null +++ b/authentication/cfg/cfg.go @@ -0,0 +1,55 @@ +package cfg + +import ( + "github.com/spf13/viper" + "gitlab.com/isard/isardvdi/pkg/cfg" +) + +type Cfg struct { + Log cfg.Log + DB cfg.DB + HTTP cfg.HTTP + Authentication Authentication +} + +type HTTP struct { + Host string + Port int +} + +type Authentication struct { + Host string + Secret string + Local bool + Google AuthenticationGoogle +} + +type AuthenticationGoogle struct { + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` +} + +func New() Cfg { + config := &Cfg{} + + cfg.New("authentication", setDefaults, config) + + return *config +} + +func setDefaults() { + cfg.SetDBDefaults() + cfg.SetHTTPDefaults() + + viper.BindEnv("authentication.secret", "API_ISARDVDI_SECRET") + + viper.SetDefault("authentication", map[string]interface{}{ + "host": "", + "secret": "", + "local": true, + "google": map[string]interface{}{ + "client_id": "", + "client_secret": "", + }, + }) +} diff --git a/authentication/cmd/authentication/main.go b/authentication/cmd/authentication/main.go new file mode 100644 index 000000000..7ad1885a1 --- /dev/null +++ b/authentication/cmd/authentication/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + + "gitlab.com/isard/isardvdi/authentication/authentication" + "gitlab.com/isard/isardvdi/authentication/cfg" + "gitlab.com/isard/isardvdi/authentication/transport/http" + "gitlab.com/isard/isardvdi/pkg/db" + "gitlab.com/isard/isardvdi/pkg/log" +) + +func main() { + cfg := cfg.New() + + log := log.New("authentication", cfg.Log.Level) + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + + db, err := db.New(cfg.DB) + if err != nil { + log.Fatal().Err(err).Msg("connect to the database") + } + + authentication := authentication.Init(cfg.Authentication, db) + + http := &http.AuthenticationServer{ + Addr: cfg.HTTP.Addr(), + Authentication: authentication, + Log: log, + WG: &wg, + } + + go http.Serve(ctx) + wg.Add(1) + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt) + + <-stop + fmt.Println("") + log.Info().Msg("stopping service") + + cancel() + wg.Wait() +} diff --git a/authentication/model/user.go b/authentication/model/user.go new file mode 100644 index 000000000..0034e81fa --- /dev/null +++ b/authentication/model/user.go @@ -0,0 +1,62 @@ +package model + +import ( + "context" + "errors" + "fmt" + "strings" + + r "gopkg.in/rethinkdb/rethinkdb-go.v6" +) + +const userIDSFieldSeparator = "-" + +var ErrNotFound = errors.New("not found") + +// User is an user of IsardVDI +type User struct { + UID string `rethinkdb:"uid"` + Username string `rethinkdb:"username"` + Password string `rethinkdb:"password"` + Provider string `rethinkdb:"provider"` + + Category string `rethinkdb:"category"` + Role string `rethinkdb:"role"` + Group string `rethinkdb:"group"` + + Name string `rethinkdb:"name"` + Email string `rethinkdb:"email"` + Photo string `rethinkdb:"photo"` +} + +func (u *User) Load(ctx context.Context, sess r.QueryExecutor) error { + res, err := r.Table("users").Get(u.ID()).Run(sess) + if err != nil { + return err + } + defer res.Close() + + if err := res.One(u); err != nil { + if errors.Is(err, r.ErrEmptyResult) { + return ErrNotFound + } + + return fmt.Errorf("read db response: %w", err) + } + + return nil +} + +func (u *User) ID() string { + return strings.Join([]string{u.Provider, u.Category, u.UID, u.Username}, userIDSFieldSeparator) +} + +func (u *User) Exists(ctx context.Context, sess r.QueryExecutor) (bool, error) { + res, err := r.Table("users").Get(u.ID()).Run(sess) + if err != nil { + return false, err + } + defer res.Close() + + return !res.IsNil(), nil +} diff --git a/authentication/model/user_test.go b/authentication/model/user_test.go new file mode 100644 index 000000000..8580fd05b --- /dev/null +++ b/authentication/model/user_test.go @@ -0,0 +1,102 @@ +package model_test + +import ( + "context" + "testing" + + "gitlab.com/isard/isardvdi/authentication/model" + + "github.com/stretchr/testify/assert" + r "gopkg.in/rethinkdb/rethinkdb-go.v6" +) + +func TestUserLoad(t *testing.T) { + assert := assert.New(t) + + cases := map[string]struct { + PrepareTest func(*r.Mock) + User *model.User + ExpectedUser *model.User + ExpectedErr string + }{ + "should work as expected": { + PrepareTest: func(m *r.Mock) { + m.On(r.Table("users").Get("local-default-admin-admin")).Return([]interface{}{ + map[string]interface{}{ + "uid": "admin", + "username": "admin", + "password": "f0ckt3Rf$", + "provider": "local", + "category": "default", + "role": "default", + "group": "default", + "name": "Administrator", + "email": "admin@isardvdi.com", + "photo": "https://isardvdi.com/path/to/photo.jpg", + }, + }, nil) + }, + User: &model.User{ + Provider: "local", + Category: "default", + UID: "admin", + Username: "admin", + }, + ExpectedUser: &model.User{ + UID: "admin", + Username: "admin", + Password: "f0ckt3Rf$", + Provider: "local", + Category: "default", + Role: "default", + Group: "default", + Name: "Administrator", + Email: "admin@isardvdi.com", + Photo: "https://isardvdi.com/path/to/foto.jpg", + }, + }, + "should return an error if there's an error querying the DB": { + PrepareTest: func(m *r.Mock) { + m = nil + }, + User: &model.User{ + Provider: "local", + Category: "default", + UID: "admin", + Username: "admin", + }, + ExpectedErr: ":)", + }, + "should return not found if the user is not found": { + PrepareTest: func(m *r.Mock) { + m.On(r.Table("users").Get("local-default-fakeuser-fakeuser")).Return([]interface{}{}, nil) + }, + User: &model.User{ + Provider: "local", + Category: "default", + UID: "fakeuser", + Username: "fakeuser", + }, + ExpectedUser: &model.User{}, + ExpectedErr: model.ErrNotFound.Error(), + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + mock := r.NewMock() + + tc.PrepareTest(mock) + + err := tc.User.Load(context.Background(), mock) + + if tc.ExpectedErr == "" { + assert.NoError(err) + } else { + assert.EqualError(err, tc.ExpectedErr) + } + + mock.AssertExpectations(t) + }) + } +} diff --git a/authentication/transport/http/helpers.go b/authentication/transport/http/helpers.go new file mode 100644 index 000000000..57a3c7cf0 --- /dev/null +++ b/authentication/transport/http/helpers.go @@ -0,0 +1,60 @@ +package http + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "gitlab.com/isard/isardvdi/authentication/authentication/provider" +) + +type argsJSON struct { + Provider string `json:"provider,omitempty"` + CategoryID string `json:"category_id,omitempty"` +} + +// TODO: Parse args depending on the content type +func parseArgs(r *http.Request) (map[string]string, error) { + r.ParseMultipartForm(32 << 20) + + args := map[string]string{} + for k, v := range r.Form { + if len(v) == 0 { + continue + } + + args[k] = v[0] + } + + // Pass the body as argument too + defer r.Body.Close() + b, err := io.ReadAll(r.Body) + if err != nil { + return map[string]string{}, fmt.Errorf("read request body: %w", err) + } + + argsJSON := &argsJSON{} + json.Unmarshal(b, argsJSON) + if argsJSON.Provider != "" { + args[provider.ProviderArgsKey] = argsJSON.Provider + } + + if argsJSON.CategoryID != "" { + args[provider.CategoryIDArgsKey] = argsJSON.CategoryID + } + + args[provider.RequestBodyArgsKey] = string(b) + + return args, nil +} + +func requiredArgs(requiredArgs []string, args map[string]string) error { + for _, a := range requiredArgs { + if args[a] == "" { + return fmt.Errorf("%s not sent", a) + } + } + + return nil +} diff --git a/authentication/transport/http/helpers_test.go b/authentication/transport/http/helpers_test.go new file mode 100644 index 000000000..5c5091599 --- /dev/null +++ b/authentication/transport/http/helpers_test.go @@ -0,0 +1,83 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseArgs(t *testing.T) { + assert := assert.New(t) + + cases := map[string]struct { + Request func() *http.Request + ExpectedArgs map[string]string + ExpectedErr string + }{ + "should parse the GET arguments correctly": { + Request: func() *http.Request { + return httptest.NewRequest(http.MethodGet, "/?argument_test=value&argument_with_no_value", nil) + }, + ExpectedArgs: map[string]string{ + "argument_test": "value", + "argument_with_no_value": "", + "request_body": "", + }, + }, + "should parse the POST arguments correctly": { + Request: func() *http.Request { + data := url.Values{} + data.Add("argument_test", "value") + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(data.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + return req + }, + ExpectedArgs: map[string]string{ + "argument_test": "value", + "request_body": "", + }, + }, + "should parse the JSON arguments correctly": { + Request: func() *http.Request { + data := `{"provider": "local","category_id":"default","argument_test": "value","another_value": "testing"}` + + return httptest.NewRequest(http.MethodPost, "/", strings.NewReader(data)) + }, + ExpectedArgs: map[string]string{ + "provider": "local", + "category_id": "default", + "request_body": `{"provider": "local","category_id":"default","argument_test": "value","another_value": "testing"}`, + }, + }, + "should parse GET parameters with a POST request": { + Request: func() *http.Request { + return httptest.NewRequest(http.MethodPost, "/?argument_test=value&argument_with_no_value", nil) + }, + ExpectedArgs: map[string]string{ + "argument_test": "value", + "argument_with_no_value": "", + "request_body": "", + }, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + args, err := parseArgs(tt.Request()) + + assert.Equal(tt.ExpectedArgs, args) + + if tt.ExpectedErr == "" { + assert.NoError(err) + } else { + assert.EqualError(err, tt.ExpectedErr) + } + }) + } +} diff --git a/authentication/transport/http/http.go b/authentication/transport/http/http.go new file mode 100644 index 000000000..5f3c01fea --- /dev/null +++ b/authentication/transport/http/http.go @@ -0,0 +1,163 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "gitlab.com/isard/isardvdi/authentication/authentication" + "gitlab.com/isard/isardvdi/authentication/authentication/provider" + + "github.com/rs/zerolog" +) + +type AuthenticationServer struct { + Addr string + Authentication authentication.Interface + + Log *zerolog.Logger + WG *sync.WaitGroup +} + +func (a *AuthenticationServer) Serve(ctx context.Context) { + m := http.NewServeMux() + m.HandleFunc("/login", a.login) + m.HandleFunc("/callback", a.callback) + m.HandleFunc("/check", a.check) + m.HandleFunc("/config", a.config) + + s := http.Server{ + Addr: a.Addr, + Handler: m, + } + + go func() { + if err := s.ListenAndServe(); err != nil { + a.Log.Fatal().Err(err).Str("addr", a.Addr).Msg("serve http") + } + }() + + <-ctx.Done() + + timeout, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + s.Shutdown(timeout) + a.WG.Done() +} + +func (a *AuthenticationServer) login(w http.ResponseWriter, r *http.Request) { + args, err := parseArgs(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + } + + tkn := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + + if tkn == "" { + if err := requiredArgs([]string{provider.ProviderArgsKey, provider.CategoryIDArgsKey}, args); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + return + } + } + + args[provider.TokenArgsKey] = tkn + prv := args[provider.ProviderArgsKey] + cID := args[provider.CategoryIDArgsKey] + + tkn, redirect, err := a.Authentication.Login(r.Context(), prv, cID, args) + if err != nil { + // TODO: Better error handling! + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Header().Set("Authorization", "Bearer "+tkn) + + if redirect != "" { + http.Redirect(w, r, redirect, http.StatusFound) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(tkn)) +} + +func (a *AuthenticationServer) callback(w http.ResponseWriter, r *http.Request) { + args, err := parseArgs(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + } + + tkn, redirect, err := a.Authentication.Callback(r.Context(), args) + if err != nil { + // TODO: Better error handling! + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Header().Set("Authorization", "Bearer "+tkn) + c := &http.Cookie{ + Name: "authorization", + Path: "/", + Value: tkn, + Expires: time.Now().Add(5 * time.Minute), + } + http.SetCookie(w, c) + + if redirect != "" { + http.Redirect(w, r, redirect, http.StatusFound) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(tkn)) +} + +func (a *AuthenticationServer) check(w http.ResponseWriter, r *http.Request) { + tkn := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if tkn == "" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("invalid token")) + return + } + + if err := a.Authentication.Check(r.Context(), tkn); err != nil { + // TODO: Better error handling! + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(err.Error())) + return + } + + w.WriteHeader(http.StatusOK) +} + +type configJSON struct { + Providers []string `json:"providers"` +} + +func (a *AuthenticationServer) config(w http.ResponseWriter, r *http.Request) { + cfg := &configJSON{ + Providers: a.Authentication.Providers(), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(cfg); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Errorf("encode response: %w", err).Error())) + return + } +} diff --git a/authentication/transport/http/http_test.go b/authentication/transport/http/http_test.go new file mode 100644 index 000000000..ce4f1fc27 --- /dev/null +++ b/authentication/transport/http/http_test.go @@ -0,0 +1,107 @@ +package http + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "gitlab.com/isard/isardvdi/authentication/authentication" + + "github.com/stretchr/testify/assert" +) + +func TestLogin(t *testing.T) { + assert := assert.New(t) + + cases := map[string]struct { + PrepareTest func(m *authentication.AuthenticationMock) + Request *http.Request + ExpectedStatusCode int + ExpectedHeader http.Header + ExpectedBody []byte + }{ + "should login correctly": { + PrepareTest: func(m *authentication.AuthenticationMock) { + m.On("Login", context.Background(), "local", "default", map[string]string{ + "request_body": "", + "provider": "local", + "category_id": "default", + "username": "nefix", + "password": "f0cKt3Rf$", + }).Return("imaginethisisatoken", "", nil) + }, + Request: httptest.NewRequest(http.MethodGet, "/?provider=local&category_id=default&username=nefix&password=f0cKt3Rf$", nil), + ExpectedStatusCode: http.StatusOK, + ExpectedHeader: http.Header{ + "Authorization": []string{"Bearer imaginethisisatoken"}, + }, + ExpectedBody: []byte("imaginethisisatoken"), + }, + "should return an error if the provider isn't sent": { + Request: httptest.NewRequest(http.MethodGet, "/?category_id=default", nil), + ExpectedStatusCode: http.StatusBadRequest, + ExpectedBody: []byte("provider not sent"), + }, + "should return an error if there's an error logging in": { + PrepareTest: func(m *authentication.AuthenticationMock) { + m.On("Login", context.Background(), "local", "default", map[string]string{ + "request_body": "", + "provider": "local", + "category_id": "default", + "username": "nefix", + "password": "f0cKt3Rf$", + }).Return("", "", errors.New("testing error")) + }, + Request: httptest.NewRequest(http.MethodGet, "/?provider=local&category_id=default&username=nefix&password=f0cKt3Rf$", nil), + ExpectedStatusCode: http.StatusInternalServerError, + ExpectedBody: []byte("testing error"), + }, + "should redirect if the login function says so": { + PrepareTest: func(m *authentication.AuthenticationMock) { + m.On("Login", context.Background(), "local", "default", map[string]string{ + "request_body": "", + "provider": "local", + "category_id": "default", + "username": "nefix", + "password": "f0cKt3Rf$", + }).Return("imaginethisisatoken", "/", nil) + }, + Request: httptest.NewRequest(http.MethodGet, "/?provider=local&category_id=default&username=nefix&password=f0cKt3Rf$", nil), + ExpectedStatusCode: http.StatusFound, + ExpectedHeader: http.Header{ + "Authorization": []string{"Bearer imaginethisisatoken"}, + "Content-Type": []string{"text/html; charset=utf-8"}, + "Location": []string{"/"}, + }, + ExpectedBody: []byte("Found.\n\n"), + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + mock := &authentication.AuthenticationMock{} + + a := &AuthenticationServer{Authentication: mock} + + if tt.PrepareTest != nil { + tt.PrepareTest(mock) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(a.login) + handler.ServeHTTP(rr, tt.Request) + + assert.Equal(tt.ExpectedStatusCode, rr.Code) + + if tt.ExpectedHeader == nil { + tt.ExpectedHeader = http.Header{} + } + assert.Equal(tt.ExpectedHeader, rr.Header()) + assert.Equal(tt.ExpectedBody, rr.Body.Bytes()) + + mock.AssertExpectations(t) + }) + } +} diff --git a/backend/Makefile b/backend/Makefile deleted file mode 100644 index 350f4bf2c..000000000 --- a/backend/Makefile +++ /dev/null @@ -1,3 +0,0 @@ -.PHONY: build -build: - go build -o backend cmd/backend/*.go diff --git a/backend/TODO.md b/backend/TODO.md deleted file mode 100644 index 2e59cf316..000000000 --- a/backend/TODO.md +++ /dev/null @@ -1,12 +0,0 @@ -# TODO - -## Before rc1 - -- ~~Fix noVNC resize~~ -- ~~Remove panics and handle errors correctly~~ - -## After rc1 -- If you click register two times, it jumps to the next screen -- Improve logging -- Logout should redirect in the category login -- Admin button should redirect in the category admin login diff --git a/backend/api/api.go b/backend/api/api.go deleted file mode 100644 index 9d8a17e95..000000000 --- a/backend/api/api.go +++ /dev/null @@ -1,155 +0,0 @@ -package api - -import ( - "context" - "errors" - "fmt" - "net/http" - "os" - "strconv" - "strings" - - "github.com/go-redis/redis" - "github.com/gorilla/mux" - "github.com/isard-vdi/isard/backend/auth" - "github.com/isard-vdi/isard/backend/auth/provider" - "github.com/isard-vdi/isard/backend/env" - "github.com/isard-vdi/isard/backend/model" - "github.com/isard-vdi/isard/backend/pkg/utils" -) - -type contextKey int - -const ( - version = "v2" - mainteinanceRedisKey = "manteinance" - usrCtxKey contextKey = 0 -) - -var ( - manteinanceAdmins []string -) - -func init() { - manteinanceAdmins = strings.Split(os.Getenv("BACKEND_MANTEINANCE_ADMINS"), ",") -} - -// API is the main API handler -type API struct { - env *env.Env - Mux *mux.Router -} - -// New creates the API handler -func New(env *env.Env) *API { - a := &API{ - env, - mux.NewRouter(), - } - - a.Mux.HandleFunc("/api/"+version+"/config", a.configuration) - a.Mux.HandleFunc("/api/"+version+"/categories", a.categories) - a.Mux.HandleFunc("/api/"+version+"/category/{category}", a.category) - - a.Mux.HandleFunc("/api/"+version+"/login/", a.login) - a.Mux.HandleFunc("/api/"+version+"/login/{category}", a.login) - a.Mux.HandleFunc("/api/"+version+"/register", a.register) - - a.Mux.HandleFunc("/callback/{provider}", func(w http.ResponseWriter, r *http.Request) { - provider.Callback(env, w, r) - }) - a.Mux.HandleFunc("/api/"+version+"/logout", a.isAuthenticated(a.logout)) - a.Mux.HandleFunc("/api/"+version+"/logout/remote", a.isAuthenticated(a.remoteLogout)) - - a.Mux.HandleFunc("/api/"+version+"/check", a.isAuthenticated(a.check)) - a.Mux.HandleFunc("/api/"+version+"/user", a.isAuthenticated(a.user)) - a.Mux.HandleFunc("/api/"+version+"/vpn", a.isAuthenticated(a.vpn)) - a.Mux.HandleFunc("/api/"+version+"/templates", a.isAuthenticated(a.templates)) - a.Mux.HandleFunc("/api/"+version+"/create", a.isAuthenticated(a.create)) - - a.Mux.HandleFunc("/api/"+version+"/desktops", a.isAuthenticated(a.desktops)) - a.Mux.HandleFunc("/api/"+version+"/desktop/{desktop}", a.isAuthenticated(a.desktopDelete)).Methods("DELETE") - a.Mux.HandleFunc("/api/"+version+"/desktop/{desktop}/start", a.isAuthenticated(a.desktopStart)) - a.Mux.HandleFunc("/api/"+version+"/desktop/{desktop}/stop", a.isAuthenticated(a.desktopStop)) - a.Mux.HandleFunc("/api/"+version+"/desktop/{desktop}/viewer/{viewerType}", a.isAuthenticated(a.desktopViewer)) - a.Mux.HandleFunc("/api/"+version+"/check-desktop/{ip}", a.isAuthenticated(a.checkDesktop)) - - a.Mux.HandleFunc("/api/"+version+"/deployments", a.isAuthenticated(a.deployments)) - a.Mux.HandleFunc("/api/"+version+"/deployment/{deployment}", a.isAuthenticated(a.deployment)) - - return a -} - -func (a *API) isAuthenticated(handler http.HandlerFunc) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - manteinance, err := a.env.Redis.WithContext(r.Context()).Do("GET", mainteinanceRedisKey).Bool() - if err != nil { - if !errors.Is(err, redis.Nil) { - a.env.Sugar.Errorw("get manteinance status", - "err", err, - ) - - w.WriteHeader(http.StatusInternalServerError) - return - } - } - - if manteinance { - http.Error(w, "service in mainteinance", http.StatusServiceUnavailable) - return - } - - c, err := r.Cookie(provider.SessionStoreKey) - if err != nil || c == nil { - http.Error(w, "Unauthenticated", http.StatusUnauthorized) - return - } - - u, err := auth.IsAuthenticated(r.Context(), a.env, c) - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return - } - - r = r.WithContext(context.WithValue(r.Context(), usrCtxKey, u)) - - isardCookie, err := getCookie(r) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if err := isardCookie.update(u, w); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - handler.ServeHTTP(w, r) - }) -} - -func getUsr(ctx context.Context) *model.User { - usr, _ := ctx.Value(usrCtxKey).(*model.User) - return usr -} - -func handleErr(err error, w http.ResponseWriter, r *http.Request) { - var e *utils.ErrHTTPCode - if errors.As(err, &e) { - w.WriteHeader(e.Code) - } else { - w.WriteHeader(http.StatusInternalServerError) - } - - fmt.Fprint(w, err) -} - -func handleErrRedirect(err error, w http.ResponseWriter, r *http.Request) { - var e *utils.ErrHTTPCode - if errors.As(err, &e) { - http.Redirect(w, r, "/error/"+strconv.Itoa(e.Code), http.StatusFound) - return - } - - http.Redirect(w, r, "/error/500", http.StatusFound) -} diff --git a/backend/api/category.go b/backend/api/category.go deleted file mode 100644 index 3e28f0744..000000000 --- a/backend/api/category.go +++ /dev/null @@ -1,21 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/gorilla/mux" -) - -func (a *API) categories(w http.ResponseWriter, r *http.Request) { - a.env.Isard.CategoryList(w, r) -} - -func (a *API) category(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - category, _ := vars["category"] - if category == "" { - category = "default" - } - - a.env.Isard.CategoryLoad(category, w, r) -} diff --git a/backend/api/check.go b/backend/api/check.go deleted file mode 100644 index 23862af86..000000000 --- a/backend/api/check.go +++ /dev/null @@ -1,46 +0,0 @@ -package api - -import ( - "net/http" - "net/url" - - "github.com/gorilla/mux" -) - -const redirectKey = "redirect" - -func (a *API) check(w http.ResponseWriter, r *http.Request) { - redirect := r.URL.Query().Get(redirectKey) - if redirect == "" { - w.WriteHeader(http.StatusOK) - w.Write([]byte(getUsr(r.Context()).ID())) - return - } - - u, err := url.Parse(redirect) - if err != nil { - http.Error(w, "invalid redirect url", http.StatusBadRequest) - return - } - - http.Redirect(w, r, u.String(), http.StatusFound) -} - -func (a *API) checkDesktop(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - ip, ok := vars["ip"] - if !ok { - http.Error(w, "unknown desktop", http.StatusBadRequest) - return - } - - u := getUsr(r.Context()) - for _, desktop := range u.Desktops { - if desktop.IP == ip { - w.WriteHeader(http.StatusOK) - return - } - } - - http.Error(w, "permission denied", http.StatusForbidden) -} diff --git a/backend/api/config.go b/backend/api/config.go deleted file mode 100644 index 90f55df7e..000000000 --- a/backend/api/config.go +++ /dev/null @@ -1,18 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" -) - -func (a *API) configuration(w http.ResponseWriter, r *http.Request) { - b, err := json.Marshal(a.env.Cfg.Frontend) - if err != nil { - http.Error(w, "marshal frontend configuration", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - w.Header().Set("Content-Type", "application/json") - w.Write(b) -} diff --git a/backend/api/cookie.go b/backend/api/cookie.go deleted file mode 100644 index 2449fc530..000000000 --- a/backend/api/cookie.go +++ /dev/null @@ -1,76 +0,0 @@ -package api - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/isard-vdi/isard/backend/model" -) - -type cookie struct { - Name string `json:"name"` - DesktopID string `json:"desktop_id"` -} - -const cookieName = "isard" - -func (c *cookie) save(w http.ResponseWriter) error { - b, err := json.Marshal(c) - if err != nil { - return fmt.Errorf("save cookie: encode json: %v", err) - } - - http.SetCookie(w, &http.Cookie{ - Name: cookieName, - Value: base64.StdEncoding.EncodeToString(b), - SameSite: http.SameSiteStrictMode, - Path: "/", - Expires: time.Now().AddDate(0, 0, 1), - }) - - return nil -} - -func (c *cookie) update(u *model.User, w http.ResponseWriter) error { - c.Name = u.Name - - if err := c.save(w); err != nil { - return err - } - - return nil -} - -func getCookie(r *http.Request) (*cookie, error) { - c, err := r.Cookie(cookieName) - if err != nil { - return &cookie{}, nil - } - - val, err := base64.StdEncoding.DecodeString(c.Value) - if err != nil { - return nil, fmt.Errorf("get cookie: decode base64: %v", err) - } - - var v cookie - if err := json.Unmarshal(val, &v); err != nil { - return nil, fmt.Errorf("get cookie: decode json: %v", err) - } - - return &v, nil -} - -func delCookie(w http.ResponseWriter, r *http.Request) { - c, err := r.Cookie(cookieName) - if err == nil { - c.Value = "" - c.Path = "/" - c.MaxAge = -1 - c.Expires = time.Unix(1, 0) - - http.SetCookie(w, c) - } -} diff --git a/backend/api/create.go b/backend/api/create.go deleted file mode 100644 index 7c04f592f..000000000 --- a/backend/api/create.go +++ /dev/null @@ -1,33 +0,0 @@ -package api - -import ( - "net/http" -) - -const ( - createTemplateKey = "template" -) - -func (a *API) create(w http.ResponseWriter, r *http.Request) { - u := getUsr(r.Context()) - tmpl := r.FormValue(createTemplateKey) - - c, err := getCookie(r) - if err != nil { - c = &cookie{} - } - - id, err := a.env.Isard.DesktopCreate(u, tmpl, false) - if err != nil { - handleErr(err, w, r) - return - } - - c.DesktopID = id - if err := c.update(u, w); err != nil { - handleErr(err, w, r) - return - } - - w.WriteHeader(http.StatusCreated) -} diff --git a/backend/api/deployments.go b/backend/api/deployments.go deleted file mode 100644 index 3bd29d8eb..000000000 --- a/backend/api/deployments.go +++ /dev/null @@ -1,80 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/gorilla/mux" - "github.com/isard-vdi/isard/backend/model" -) - -func checkDeploymentAuthorizationByID(u *model.User, id string) error { - found := false - for _, deployment := range u.Deployments { - if deployment.ID == id { - found = true - break - } - } - - if !found { - return errors.New("permission denied") - } - - return nil -} - -func (a *API) deployments(w http.ResponseWriter, r *http.Request) { - u := getUsr(r.Context()) - /* - json.Marshal returns null if desktops are an empty array - See also: - https://github.com/golang/go/issues/27589 - https://github.com/golang/go/issues/37711 - */ - var err error - b := []byte("[]") - if u.Deployments != nil { - b, err = json.Marshal(u.Deployments) - if err != nil { - http.Error(w, "cannot encode deployments", http.StatusBadRequest) - return - } - } - - a.env.Sugar.Infow("deployments", - "usr", u.ID(), - ) - - w.Header().Set("Content-Type", "application/json") - w.Write(b) -} - -func (a *API) deployment(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - d, ok := vars["deployment"] - if !ok { - http.Error(w, "unknown deployment", http.StatusBadRequest) - return - } - - u := getUsr(r.Context()) - if err := checkDeploymentAuthorizationByID(u, d); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - - deployment, err := a.env.Isard.DeploymentGet(u, d) - if err != nil { - handleErr(err, w, r) - return - } - - a.env.Sugar.Infow("deployment", - "usr", u.ID(), - ) - - w.Header().Set("Content-Type", "application/json") - w.Write(deployment) -} diff --git a/backend/api/desktops.go b/backend/api/desktops.go deleted file mode 100644 index b2bc47ce2..000000000 --- a/backend/api/desktops.go +++ /dev/null @@ -1,192 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/gorilla/mux" - "github.com/isard-vdi/isard/backend/model" -) - -func checkDesktopAuthorizationByID(u *model.User, id string) error { - found := false - for _, desktop := range u.Desktops { - if desktop.ID == id { - found = true - break - } - } - - if !found { - return errors.New("permission denied") - } - - return nil -} - -type viewerResponse struct { - Type string `json:"type,omitempty"` - Content string `json:"content,omitempty"` -} - -func (a *API) desktops(w http.ResponseWriter, r *http.Request) { - u := getUsr(r.Context()) - /* - json.Marshal returns null if desktops are an empty array - See also: - https://github.com/golang/go/issues/27589 - https://github.com/golang/go/issues/37711 - */ - var err error - b := []byte("[]") - if u.Desktops != nil { - b, err = json.Marshal(u.Desktops) - if err != nil { - http.Error(w, "cannot encode desktops", http.StatusBadRequest) - return - } - } - - w.Header().Set("Content-Type", "application/json") - w.Write(b) -} - -func (a *API) desktopStart(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - d, ok := vars["desktop"] - if !ok { - http.Error(w, "unknown desktop", http.StatusBadRequest) - return - } - u := getUsr(r.Context()) - if err := checkDesktopAuthorizationByID(u, d); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - - if err := a.env.Isard.DesktopStart(d); err != nil { - handleErr(err, w, r) - return - } - - c, err := getCookie(r) - if err != nil { - handleErr(err, w, r) - return - } - - c.DesktopID = d - if err := c.save(w); err != nil { - handleErr(err, w, r) - return - } - - a.env.Sugar.Infow("desktop start", - "desktop", d, - "usr", u.ID(), - ) - - w.WriteHeader(http.StatusOK) -} - -func (a *API) desktopStop(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - d, ok := vars["desktop"] - if !ok { - http.Error(w, "unknown desktop", http.StatusBadRequest) - return - } - - u := getUsr(r.Context()) - if err := checkDesktopAuthorizationByID(u, d); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - - if err := a.env.Isard.DesktopStop(d); err != nil { - handleErr(err, w, r) - return - } - - c, err := getCookie(r) - if err != nil { - handleErr(err, w, r) - return - } - - c.DesktopID = d - if err := c.save(w); err != nil { - handleErr(err, w, r) - return - } - - a.env.Sugar.Infow("desktop stop", - "desktop", d, - "usr", u.ID(), - ) - - w.WriteHeader(http.StatusOK) -} - -func (a *API) desktopDelete(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - d, ok := vars["desktop"] - if !ok { - http.Error(w, "unknown desktop", http.StatusBadRequest) - return - } - - u := getUsr(r.Context()) - if err := checkDesktopAuthorizationByID(u, d); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - - if err := a.env.Isard.DesktopDelete(d); err != nil { - handleErr(err, w, r) - return - } - - a.env.Sugar.Infow("desktop delete", - "desktop", d, - "usr", u.ID(), - ) - - w.WriteHeader(http.StatusOK) -} - -func (a *API) desktopViewer(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - d, ok := vars["desktop"] - if !ok { - http.Error(w, "unknown desktop", http.StatusBadRequest) - return - } - - viewerType, ok := vars["viewerType"] - if !ok { - http.Error(w, "unknown viewer type", http.StatusBadRequest) - return - } - - u := getUsr(r.Context()) - if err := checkDesktopAuthorizationByID(u, d); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - - viewer, err := a.env.Isard.Viewer(d, viewerType) - if err != nil { - handleErr(err, w, r) - return - } - - a.env.Sugar.Infow("viewer", - "type", viewerType, - "usr", u.ID(), - ) - - w.Header().Set("Content-Type", "application/json") - w.Write(viewer) -} diff --git a/backend/api/login.go b/backend/api/login.go deleted file mode 100644 index c7bde7cc4..000000000 --- a/backend/api/login.go +++ /dev/null @@ -1,27 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/gorilla/mux" - "github.com/isard-vdi/isard/backend/auth/provider" -) - -func (a *API) login(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - category, _ := vars[provider.CategoryKey] - - p := provider.FromString(r.FormValue("provider")) - if p.String() == "unknown" { - http.Error(w, "unknown identity provider", http.StatusBadRequest) - return - } - - q := r.URL.Query() - if q.Get("category") == "" { - q.Add(provider.CategoryKey, category) - } - r.URL.RawQuery = q.Encode() - - p.Login(a.env, w, r) -} diff --git a/backend/api/logout.go b/backend/api/logout.go deleted file mode 100644 index 59e144b95..000000000 --- a/backend/api/logout.go +++ /dev/null @@ -1,65 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/isard-vdi/isard/backend/auth/provider" - "github.com/isard-vdi/isard/backend/isardAdmin" -) - -func (a *API) internalLogout(w http.ResponseWriter, r *http.Request) { - c, err := getCookie(r) - if err == nil { - user := getUsr(r.Context()) - for _, desktop := range user.Desktops { - if desktop.Type == "nonpersistent" { - if err := a.env.Isard.DesktopDelete(desktop.ID); err != nil { - a.env.Sugar.Errorw("delete desktop", - "err", err, - "id", desktop.ID, - ) - } - } - } - } - - // Delete the user info cookie - delCookie(w, r) - - // Delete the session cookie - s, _ := a.env.Auth.SessStore.Get(r, provider.SessionStoreKey) - if !s.IsNew { - s.Options.MaxAge = -1 - - if err := s.Save(r, w); err != nil { - a.env.Sugar.Errorw("get user session", - "err", err, - "id", c.DesktopID, - ) - - http.Redirect(w, r, "/", http.StatusFound) - } - } - - u := getUsr(r.Context()) - a.env.Sugar.Infow("logout", - "usr", u.ID(), - ) -} - -func (a *API) remoteLogout(w http.ResponseWriter, r *http.Request) { - a.internalLogout(w, r) - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("{\"success\": true}")) -} - -func (a *API) logout(w http.ResponseWriter, r *http.Request) { - err := isardAdmin.Logout(w, r) - if err != nil { - a.env.Sugar.Errorw("isard-admin logout", - "error", err, - ) - } - a.internalLogout(w, r) - http.Redirect(w, r, "/", http.StatusFound) -} diff --git a/backend/api/register.go b/backend/api/register.go deleted file mode 100644 index f1e615486..000000000 --- a/backend/api/register.go +++ /dev/null @@ -1,42 +0,0 @@ -package api - -import ( - "bytes" - "encoding/base64" - "encoding/gob" - "errors" - "fmt" - "net/http" - - "github.com/isard-vdi/isard/backend/auth/provider" -) - -func (a *API) register(w http.ResponseWriter, r *http.Request) { - c, err := r.Cookie(provider.AutoRegistrationCookieKey) - if err != nil { - handleErr(errors.New("no registration cookie found. Login first"), w, r) - return - } - - b, err := base64.StdEncoding.DecodeString(c.Value) - if err != nil { - a.env.Sugar.Errorw("decode autoregistration cookie: base64", - "err", err, - ) - - handleErr(fmt.Errorf("decode autoregistration cookie: base64: %w", err), w, r) - return - } - - autoRegistrationCookie := &provider.AutoRegistrationCookieStruct{} - if err := gob.NewDecoder(bytes.NewBuffer(b)).Decode(autoRegistrationCookie); err != nil { - a.env.Sugar.Errorw("decode autoregistration cookie: gob", - "err", err, - ) - - handleErr(fmt.Errorf("decode autoregistration cookie: gob: %w", err), w, r) - return - } - - autoRegistrationCookie.Provider.NewSession(a.env, w, r, autoRegistrationCookie.User, autoRegistrationCookie.Val) -} diff --git a/backend/api/templates.go b/backend/api/templates.go deleted file mode 100644 index 7fd0229b9..000000000 --- a/backend/api/templates.go +++ /dev/null @@ -1,27 +0,0 @@ -package api - -import ( - "net/http" - "encoding/json" -) - -func (a *API) templates(w http.ResponseWriter, r *http.Request) { - u := getUsr(r.Context()) - /* - json.Marshal returns null if templates are an empty array - See also: - https://github.com/golang/go/issues/27589 - https://github.com/golang/go/issues/37711 - */ - var err error - b := []byte("[]") - if u.Templates != nil { - b, err = json.Marshal(u.Templates) - if err != nil { - http.Error(w, "cannot encode templates", http.StatusBadRequest) - return - } - } - w.Header().Set("Content-Type", "application/json") - w.Write(b) -} diff --git a/backend/api/user.go b/backend/api/user.go deleted file mode 100644 index 8b08165c1..000000000 --- a/backend/api/user.go +++ /dev/null @@ -1,17 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" -) - -func (a *API) user(w http.ResponseWriter, r *http.Request) { - u := getUsr(r.Context()) - b, err := json.Marshal(u) - if err != nil { - http.Error(w, "cannot encode user", http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(b) -} diff --git a/backend/api/vpn.go b/backend/api/vpn.go deleted file mode 100644 index 0ad4c8c56..000000000 --- a/backend/api/vpn.go +++ /dev/null @@ -1,19 +0,0 @@ -package api - -import ( - "net/http" -) - -func (a *API) vpn(w http.ResponseWriter, r *http.Request) { - u := getUsr(r.Context()) - a.env.Sugar.Infow("vpn", - "usr", u.ID(), - ) - viewer, err := a.env.Isard.Vpn(u) - if err != nil { - handleErr(err, w, r) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(viewer) -} diff --git a/backend/auth/auth.go b/backend/auth/auth.go deleted file mode 100644 index 2330578cf..000000000 --- a/backend/auth/auth.go +++ /dev/null @@ -1,77 +0,0 @@ -package auth - -import ( - "context" - "errors" - "fmt" - "log" - "net/http" - - "github.com/isard-vdi/isard/backend/auth/provider" - "github.com/isard-vdi/isard/backend/cfg" - "github.com/isard-vdi/isard/backend/env" - "github.com/isard-vdi/isard/backend/model" - - "github.com/go-redis/redis" - "github.com/rbcervilla/redisstore" -) - -// Errors that can be returned by IsAuthenticated -var ( - ErrNoSession = errors.New("not authenticated") - ErrSessionExpired = errors.New("session expired") -) - -func Init(env *env.Env) { - env.Redis = redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%d", env.Cfg.Redis.Host, env.Cfg.Redis.Port), - Password: env.Cfg.Redis.Password, - }) - - var err error - env.Auth.SessStore, err = redisstore.NewRedisStore(env.Redis) - if err != nil { - log.Fatalf("connecting to the redis auth store: %v", err) - } - - noSAML := cfg.AuthSAML{} - if env.Cfg.Auth.SAML != noSAML { - env.Auth.SAML, err = provider.NewSAMLProvider(env) - if err != nil { - log.Fatalf("setting up the SAML auth provider: %v", err) - } - } -} - -func IsAuthenticated(ctx context.Context, env *env.Env, c *http.Cookie) (*model.User, error) { - r := &http.Request{Header: http.Header{}} - r.AddCookie(c) - - s, err := env.Auth.SessStore.Get(r, provider.SessionStoreKey) - if err != nil { - return nil, fmt.Errorf("get session: %w", err) - } - - if len(s.Values) == 0 { - return nil, ErrNoSession - } - - u := &model.User{} - u.LoadFromID(s.Values[provider.IDStoreKey].(string)) - - if err := env.Isard.UserLoad(u); err != nil { - return nil, err - } - - p := provider.FromString(s.Values[provider.ProviderStoreKey].(string)) - if err := p.Get(env, u, s.Values[provider.ValueStoreKey]); err != nil { - // TODO: ErrSessionExpired - return nil, fmt.Errorf("get user from idp: %w", err) - } - - if err := env.Isard.UserUpdate(u); err != nil { - return nil, fmt.Errorf("update user: %v", err) - } - - return u, nil -} diff --git a/backend/auth/provider/github.go b/backend/auth/provider/github.go deleted file mode 100644 index 3e34586ac..000000000 --- a/backend/auth/provider/github.go +++ /dev/null @@ -1,101 +0,0 @@ -package provider - -import ( - "encoding/gob" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "strconv" - - "github.com/isard-vdi/isard/backend/env" - "github.com/isard-vdi/isard/backend/model" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/github" -) - -func init() { - gob.Register(&GitHub{}) -} - -const provGitHub = "github" - -// GitHub implements the service provider for github.com -type GitHub struct{} - -func (GitHub) String() string { - return provGitHub -} - -func (GitHub) cfg(env *env.Env) *oauth2.Config { - return &oauth2.Config{ - ClientID: env.Cfg.Auth.GitHub.ID, - ClientSecret: env.Cfg.Auth.GitHub.Secret, - Scopes: []string{}, - Endpoint: github.Endpoint, - } -} - -func (g GitHub) Login(env *env.Env, w http.ResponseWriter, r *http.Request) { - oauth2Login(env, g, w, r) -} - -func (g GitHub) Callback(env *env.Env, w http.ResponseWriter, r *http.Request) { - oauth2Callback(env, w, r) -} - -func (g GitHub) NewSession(env *env.Env, w http.ResponseWriter, r *http.Request, u *model.User, val interface{}) { - oauth2NewSession(env, w, r, g, u, val) -} - -// TOOD: Groups / Organizations and stuff -func (g GitHub) Get(env *env.Env, u *model.User, val interface{}) error { - url := url.URL{ - Scheme: "https", - Host: env.Cfg.Auth.GitHub.Host, - Path: "/user", - } - - req, err := http.NewRequest(http.MethodGet, url.String(), http.NoBody) - if err != nil { - return fmt.Errorf("build HTTP request: %w", err) - } - req.Header.Add("Authorization", "token "+val.(*oauth2.Token).AccessToken) - req.Header.Add("Accept", "application/vnd.github.v3+json") - - rsp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("call GitHub API: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - // TODO: Handle token refresh - b, _ := ioutil.ReadAll(rsp.Body) - return fmt.Errorf("call GitHub API: http code: %d: %s", rsp.StatusCode, b) - } - - apiUsr := &githubUsrJSON{} - if err := json.NewDecoder(rsp.Body).Decode(&apiUsr); err != nil { - return fmt.Errorf("unmarshal JSON response: %w", err) - } - - u.UID = strconv.Itoa(apiUsr.UID) - u.Username = apiUsr.Username - u.Provider = g.String() - u.Name = apiUsr.Name - u.Email = apiUsr.Email - u.Photo = apiUsr.Photo - - return nil -} - -type githubUsrJSON struct { - UID int `json:"id,omitempty"` - Username string `json:"login,omitempty"` - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - Photo string `json:"avatar_url,omitempty"` -} diff --git a/backend/auth/provider/google.go b/backend/auth/provider/google.go deleted file mode 100644 index 07b746509..000000000 --- a/backend/auth/provider/google.go +++ /dev/null @@ -1,105 +0,0 @@ -package provider - -import ( - "encoding/gob" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "strings" - - "github.com/isard-vdi/isard/backend/env" - "github.com/isard-vdi/isard/backend/model" - "github.com/isard-vdi/isard/backend/pkg/utils" - "golang.org/x/oauth2" - "golang.org/x/oauth2/google" -) - -func init() { - gob.Register(&Google{}) -} - -const provGoogle = "google" - -type Google struct{} - -func (Google) String() string { - return provGoogle -} - -func (Google) cfg(env *env.Env) *oauth2.Config { - return &oauth2.Config{ - ClientID: env.Cfg.Auth.Google.ID, - ClientSecret: env.Cfg.Auth.Google.Secret, - Scopes: []string{ - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - }, - Endpoint: google.Endpoint, - RedirectURL: fmt.Sprintf("https://%s/callback/google", env.Cfg.BackendHost), - } -} - -func (g Google) Login(env *env.Env, w http.ResponseWriter, r *http.Request) { - oauth2Login(env, g, w, r) -} - -func (Google) Callback(env *env.Env, w http.ResponseWriter, r *http.Request) { - oauth2Callback(env, w, r) -} - -func (g Google) NewSession(env *env.Env, w http.ResponseWriter, r *http.Request, u *model.User, val interface{}) { - oauth2NewSession(env, w, r, g, u, val) -} - -func (g Google) Get(env *env.Env, u *model.User, val interface{}) error { - q := url.Values{"access_token": {val.(*oauth2.Token).AccessToken}} - url := url.URL{ - Scheme: "https", - Host: "www.googleapis.com", - Path: "oauth2/v2/userinfo", - RawQuery: q.Encode(), - } - - rsp, err := http.Get(url.String()) - if err != nil { - return fmt.Errorf("call Google API: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - // TODO: Handle token refresh - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - env.Sugar.Errorw("call Google API", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - ) - - return fmt.Errorf("oauth2: %w", err) - } - - apiUsr := &googleUsrJSON{} - if err := json.NewDecoder(rsp.Body).Decode(&apiUsr); err != nil { - return fmt.Errorf("unmarshal json response: %w", err) - } - - u.UID = apiUsr.UID - u.Username = strings.Split(apiUsr.Email, "@")[0] - u.Provider = g.String() - u.Name = apiUsr.Name - u.Email = apiUsr.Email - u.Photo = apiUsr.Photo - - return nil -} - -type googleUsrJSON struct { - UID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - Photo string `json:"picture,omitempty"` -} diff --git a/backend/auth/provider/local.go b/backend/auth/provider/local.go deleted file mode 100644 index 3d14108a2..000000000 --- a/backend/auth/provider/local.go +++ /dev/null @@ -1,102 +0,0 @@ -package provider - -import ( - "errors" - "fmt" - "net/http" - - "github.com/isard-vdi/isard/backend/env" - "github.com/isard-vdi/isard/backend/model" - "github.com/isard-vdi/isard/backend/pkg/utils" -) - -const provLocal = "local" - -const ( - userKey = "usr" - passwordKey = "pwd" -) - - -type Local struct{} - -func (Local) String() string { - return provLocal -} - -func (l Local) Login(env *env.Env, w http.ResponseWriter, r *http.Request) { - usr := r.FormValue(userKey) - if usr == "" { - http.Error(w, "no user was provided", http.StatusBadRequest) - return - } - - pwd := r.FormValue(passwordKey) - if pwd == "" { - http.Error(w, "no password was provided", http.StatusBadRequest) - return - } - - u := &model.User{ - Provider: l.String(), - UID: usr, - Username: usr, - Category: getCategory(r), - } - - if err := env.Isard.Login(u, pwd); err != nil { - var e *utils.ErrHTTPCode - if errors.As(err, &e) { - if e.Code == http.StatusForbidden { - w.WriteHeader(http.StatusForbidden) - w.Write([]byte(`

Incorrect login credentials

Go back`)) - return - } - } - - http.Error(w, err.Error(), http.StatusForbidden) - return - } - - if err := l.Get(env, u, nil); err != nil { - http.Error(w, fmt.Sprintf("get user from idp: %v", err), http.StatusInternalServerError) - return - } - - l.NewSession(env, w, r, u, nil) -} - -func (l Local) Callback(env *env.Env, w http.ResponseWriter, r *http.Request) { - redirect(w, r) -} - -func (l Local) NewSession(env *env.Env, w http.ResponseWriter, r *http.Request, u *model.User, val interface{}) { - if err := autoRegistration(env, u, w, r); err != nil { - if errors.Is(err, ErrNoAutoRegistrationKey) { - autoRegistrationCookie(env, w, r, l, u, val) - return - } - - http.Error(w, fmt.Sprintf("create session: %v", err), http.StatusInternalServerError) - } - - sess, err := env.Auth.SessStore.New(r, SessionStoreKey) - if err != nil { - http.Error(w, fmt.Sprintf("create session: %v", err), http.StatusInternalServerError) - return - } - - sess.Values[ProviderStoreKey] = l.String() - sess.Values[IDStoreKey] = u.ID() - - if err := sess.Save(r, w); err != nil { - http.Error(w, fmt.Sprintf("save session: %v", err), http.StatusInternalServerError) - return - } - - redirect(w, r) -} - -func (l Local) Get(env *env.Env, u *model.User, val interface{}) error { - return nil -} diff --git a/backend/auth/provider/oauth2.go b/backend/auth/provider/oauth2.go deleted file mode 100644 index 53f841070..000000000 --- a/backend/auth/provider/oauth2.go +++ /dev/null @@ -1,146 +0,0 @@ -package provider - -import ( - "bytes" - "encoding/base64" - "encoding/gob" - "fmt" - "net/http" - - "github.com/isard-vdi/isard/backend/env" - "github.com/isard-vdi/isard/backend/model" - - "github.com/segmentio/ksuid" - "golang.org/x/oauth2" -) - -const ( - oauth2StateKey = "state" - oauth2CodeKey = "code" -) - -func init() { - gob.Register(&oauth2.Token{}) - gob.Register(&oauth2Info{}) -} - -type oauth2Provider interface { - Provider - - cfg(env *env.Env) *oauth2.Config -} - -type oauth2Info struct { - State, - Category, - Redirect string -} - -func oauth2Login(env *env.Env, p oauth2Provider, w http.ResponseWriter, r *http.Request) { - info := &oauth2Info{ - State: ksuid.New().String(), - Category: r.URL.Query().Get(CategoryKey), - Redirect: r.URL.Query().Get(redirectKey), - } - - sess, err := env.Auth.SessStore.New(r, info.State) - if err != nil { - http.Error(w, fmt.Sprintf("create session: %v", err), http.StatusInternalServerError) - return - } - - sess.Options.MaxAge = 60 * 10 // 10 mins - sess.Values[ProviderStoreKey] = p.String() - - if err := sess.Save(r, w); err != nil { - http.Error(w, fmt.Sprintf("save session: %v", err), http.StatusInternalServerError) - return - } - - buf := bytes.NewBuffer(nil) - if err := gob.NewEncoder(buf).Encode(info); err != nil { - http.Error(w, fmt.Sprintf("encode oauth2 info: %v", err), http.StatusInternalServerError) - return - } - - enc := base64.StdEncoding.EncodeToString(buf.Bytes()) - - http.Redirect(w, r, p.cfg(env).AuthCodeURL(enc), http.StatusFound) -} - -func oauth2Callback(env *env.Env, w http.ResponseWriter, r *http.Request) { - oauth2State := r.FormValue(oauth2StateKey) - - b, err := base64.StdEncoding.DecodeString(oauth2State) - if err != nil { - http.Error(w, "invalid state", http.StatusBadRequest) - return - } - - info := &oauth2Info{} - gob.NewDecoder(bytes.NewBuffer(b)).Decode(info) - - q := r.URL.Query() - q.Add(CategoryKey, info.Category) - q.Add(redirectKey, info.Redirect) - r.URL.RawQuery = q.Encode() - - state, err := env.Auth.SessStore.Get(r, info.State) - if err != nil { - http.Error(w, fmt.Sprintf("get state session: %v", err), http.StatusInternalServerError) - return - } - - prov, ok := state.Values[ProviderStoreKey].(string) - if !ok { - http.Error(w, "invalid state", http.StatusBadRequest) - return - } - - p := FromString(prov).(oauth2Provider) - - // Remove the state session - state.Options.MaxAge = -1 - if err := state.Save(r, w); err != nil { - http.Error(w, fmt.Sprintf("delete session: %v", err), http.StatusInternalServerError) - return - } - - tkn, err := p.cfg(env).Exchange(r.Context(), r.FormValue(oauth2CodeKey)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - u := &model.User{Category: getCategory(r)} - if err := p.Get(env, u, tkn); err != nil { - http.Error(w, fmt.Sprintf("get user from idp: %v", err), http.StatusInternalServerError) - return - } - - oauth2NewSession(env, w, r, p, u, tkn) -} - -func oauth2NewSession(env *env.Env, w http.ResponseWriter, r *http.Request, p Provider, u *model.User, val interface{}) { - if err := autoRegistration(env, u, w, r); err != nil { - autoRegistrationCookie(env, w, r, p, u, val) - return - } - - sess, err := env.Auth.SessStore.New(r, SessionStoreKey) - if err != nil { - http.Error(w, fmt.Sprintf("create session: %v", err), http.StatusInternalServerError) - return - } - - sess.Values[ProviderStoreKey] = p.String() - sess.Values[IDStoreKey] = u.ID() - sess.Values[ValueStoreKey] = val - - if err := sess.Save(r, w); err != nil { - http.Error(w, fmt.Sprintf("save session: %v", err), http.StatusInternalServerError) - return - } - - redirect(w, r) -} diff --git a/backend/auth/provider/provider.go b/backend/auth/provider/provider.go deleted file mode 100644 index 6293c15d6..000000000 --- a/backend/auth/provider/provider.go +++ /dev/null @@ -1,186 +0,0 @@ -package provider - -import ( - "bytes" - "encoding/base64" - "encoding/gob" - "errors" - "fmt" - "net/http" - "time" - - "github.com/gorilla/mux" - "github.com/isard-vdi/isard/backend/env" - "github.com/isard-vdi/isard/backend/model" -) - -const ( - SessionStoreKey = "session" - ProviderStoreKey = "provider" - IDStoreKey = "id" - ValueStoreKey = "value" - redirectKey = "redirect" - registerKey = "code" - CategoryKey = "category" - AutoRegistrationCookieKey = "autoregistration" -) - -var ( - ErrNoAutoRegistrationKey = errors.New("no autoregistration key was provided") -) - -func init() { - gob.Register(&AutoRegistrationCookieStruct{}) -} - -type Provider interface { - // TODO: Context - Get(env *env.Env, u *model.User, val interface{}) error - Login(env *env.Env, w http.ResponseWriter, r *http.Request) - Callback(env *env.Env, w http.ResponseWriter, r *http.Request) - NewSession(env *env.Env, w http.ResponseWriter, r *http.Request, u *model.User, val interface{}) - String() string -} - -func FromString(p string) Provider { - switch p { - case provLocal: - return &Local{} - case provGitHub: - return &GitHub{} - case provSAML: - return &SAML{} - case provGoogle: - return &Google{} - default: - return &Unknown{} - } -} - -func Callback(env *env.Env, w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - prov, ok := vars["provider"] - if !ok { - http.Error(w, "unknown identity provider", http.StatusBadRequest) - return - } - - p := FromString(prov) - if p.String() == "unknown" { - http.Error(w, "unknown identity provider", http.StatusBadRequest) - return - } - - p.Callback(env, w, r) -} - -func autoRegistration(env *env.Env, u *model.User, w http.ResponseWriter, r *http.Request) error { - if err := env.Isard.UserLoad(u); err != nil { - if errors.Is(err, model.ErrNotFound) { - if env.Cfg.Auth.AutoRegistration { - code := r.FormValue(registerKey) - if code == "" { - return ErrNoAutoRegistrationKey - } - - if err := env.Isard.CheckRegistrationCode(u, code); err != nil { - return fmt.Errorf("autoregister user: %w", err) - } - - // TODO: Local users don't have their provider updated - if err := env.Isard.UserRegister(u); err != nil { - return fmt.Errorf("autoregister user: %w", err) - } - - return nil - } - - env.Sugar.Warnf("user %s tried to autoregistrate", u.ID) - - err = errors.New("user not found and autoregistration disabled. If you think you should be able to use the service, please contact the administrator") - http.Error(w, err.Error(), http.StatusForbidden) - return err - } - - http.Error(w, err.Error(), http.StatusInternalServerError) - return err - } - - return nil -} - -func autoRegistrationCookie(env *env.Env, w http.ResponseWriter, r *http.Request, p Provider, u *model.User, val interface{}) { - cVal := &AutoRegistrationCookieStruct{ - Provider: p, - User: u, - Val: val, - } - - buf := bytes.NewBuffer(nil) - if err := gob.NewEncoder(buf).Encode(cVal); err != nil { - // This should panic, since it's a coding related issue - env.Sugar.Panicf("encode autoregistration cookie: %v", err) - } - - c := &http.Cookie{ - Name: AutoRegistrationCookieKey, - Value: base64.StdEncoding.EncodeToString(buf.Bytes()), - - Path: "/api/", - Domain: env.Cfg.BackendHost, - MaxAge: int(24 * time.Hour.Seconds()), - SameSite: http.SameSiteStrictMode, - } - - url := "/register" - redirect := r.URL.Query().Get(redirectKey) - if redirect != "" { - url = fmt.Sprintf("/register?%s=%s", redirectKey, redirect) - } - - http.SetCookie(w, c) - http.Redirect(w, r, url, http.StatusFound) -} - -type AutoRegistrationCookieStruct struct { - Provider Provider - User *model.User - Val interface{} -} - -func redirect(w http.ResponseWriter, r *http.Request) { - r.URL.Path = "/api/v2/check" - - http.Redirect(w, r, r.URL.String(), http.StatusFound) -} - -func getCategory(r *http.Request) string { - category := r.URL.Query().Get(CategoryKey) - if category == "" { - category = "default" - } - - return category -} - -type Unknown struct{} - -func (Unknown) String() string { - return "unknown" -} - -func (Unknown) Login(env *env.Env, w http.ResponseWriter, r *http.Request) { - http.Error(w, "unknown identity provider", http.StatusBadRequest) -} - -func (Unknown) Callback(env *env.Env, w http.ResponseWriter, r *http.Request) { - http.Error(w, "unknown identity provider", http.StatusBadRequest) -} - -func (Unknown) NewSession(env *env.Env, w http.ResponseWriter, r *http.Request, u *model.User, val interface{}) { - http.Error(w, "unknown identity provider", http.StatusBadRequest) -} - -func (Unknown) Get(env *env.Env, u *model.User, val interface{}) error { - return fmt.Errorf("unknown identity provider") -} diff --git a/backend/auth/provider/saml.go b/backend/auth/provider/saml.go deleted file mode 100644 index 8c786d4b7..000000000 --- a/backend/auth/provider/saml.go +++ /dev/null @@ -1,180 +0,0 @@ -package provider - -import ( - "context" - "crypto/rsa" - "crypto/tls" - "encoding/gob" - "fmt" - "net/http" - "net/url" - - "github.com/isard-vdi/isard/backend/env" - "github.com/isard-vdi/isard/backend/model" - - "github.com/crewjam/saml" - "github.com/crewjam/saml/samlsp" - "github.com/nefixestrada/pongo" - "github.com/spf13/afero" -) - -const provSAML = "saml" - -func init() { - gob.Register(&SAML{}) - gob.Register(samlsp.Attributes{}) -} - -type SAML struct{} - -func NewSAMLProvider(env *env.Env) (*samlsp.Middleware, error) { - pair, err := loadKeyPair(env) - if err != nil { - return nil, err - } - - metadata, err := loadIDPMetadata(env) - if err != nil { - return nil, err - } - - callbackURL, err := url.Parse(env.Cfg.Auth.SAML.Callback) - if err != nil { - return nil, err - } - callbackURL = callbackURL.ResolveReference(&url.URL{Path: "/callback/saml"}) - - m, err := pongo.New(env.Auth.SessStore, samlsp.Options{ - URL: *callbackURL, - Key: pair.PrivateKey.(*rsa.PrivateKey), - Certificate: pair.Leaf, - IDPMetadata: metadata, - }) - if err != nil { - return nil, fmt.Errorf("create SAML middleware: %w", err) - } - - return m, nil -} - -func (SAML) String() string { - return provSAML -} - -func (s SAML) Login(env *env.Env, w http.ResponseWriter, r *http.Request) { - session, err := env.Auth.SAML.Session.GetSession(r) - if err != nil { - if err == samlsp.ErrNoSession { - env.Auth.SAML.HandleStartAuthFlow(w, r) - } - - http.Error(w, fmt.Sprintf("error getting the session: %v", err), http.StatusInternalServerError) - return - } - - attrs := session.(samlsp.SessionWithAttributes).GetAttributes() - u := &model.User{Category: getCategory(r)} - if err := s.Get(env, u, attrs); err != nil { - http.Error(w, fmt.Sprintf("get user from idp: %v", err), http.StatusInternalServerError) - return - } - - s.NewSession(env, w, r, u, attrs) -} - -func (s SAML) Callback(env *env.Env, w http.ResponseWriter, r *http.Request) { - env.Auth.SAML.ServeHTTP(w, r) -} - -func (s SAML) NewSession(env *env.Env, w http.ResponseWriter, r *http.Request, u *model.User, val interface{}) { - if err := autoRegistration(env, u, w, r); err != nil { - autoRegistrationCookie(env, w, r, s, u, val) - - return - } - - sess, err := env.Auth.SessStore.New(r, SessionStoreKey) - if err != nil { - http.Error(w, fmt.Sprintf("create session: %v", err), http.StatusInternalServerError) - return - } - - sess.Values[ProviderStoreKey] = s.String() - sess.Values[IDStoreKey] = u.ID() - sess.Values[ValueStoreKey] = val - - if err := sess.Save(r, w); err != nil { - http.Error(w, fmt.Sprintf("save session: %v", err), http.StatusInternalServerError) - return - } - - redirect(w, r) -} - -func (s SAML) Get(env *env.Env, u *model.User, val interface{}) error { - attrs := val.(samlsp.Attributes) - - u.UID = attrs.Get(env.Cfg.Auth.SAML.AttrID) - if u.UID == "" { - return fmt.Errorf("no id for the user was found. Please, contact the administrator") - } - - u.Username = attrs.Get(env.Cfg.Auth.SAML.AttrUsername) - if u.Username == "" { - u.Username = u.UID - } - - u.Provider = s.String() - u.Name = attrs.Get(env.Cfg.Auth.SAML.AttrName) - u.Email = attrs.Get(env.Cfg.Auth.SAML.AttrEmail) - u.Photo = attrs.Get(env.Cfg.Auth.SAML.AttrPhoto) - - return nil -} - -func loadKeyPair(env *env.Env) (tls.Certificate, error) { - crt, err := afero.ReadFile(env.FS, env.Cfg.Auth.SAML.CertPath) - if err != nil { - return tls.Certificate{}, fmt.Errorf("read cert file: %w", err) - } - - key, err := afero.ReadFile(env.FS, env.Cfg.Auth.SAML.KeyPath) - if err != nil { - return tls.Certificate{}, fmt.Errorf("read key file: %w", err) - } - - pair, err := tls.X509KeyPair(crt, key) - if err != nil { - return tls.Certificate{}, fmt.Errorf("parse pair: %w", err) - } - - return pair, nil -} - -func loadIDPMetadata(env *env.Env) (*saml.EntityDescriptor, error) { - if env.Cfg.Auth.SAML.IdpMetadataPath != "" { - b, err := afero.ReadFile(env.FS, env.Cfg.Auth.SAML.IdpMetadataPath) - if err != nil { - return nil, fmt.Errorf("read idp metadata file: %w", err) - } - - m, err := samlsp.ParseMetadata(b) - if err != nil { - return nil, fmt.Errorf("parse idp metadata: %w", err) - } - - return m, nil - } - - url, err := url.Parse(env.Cfg.Auth.SAML.IdpMetadataURL) - if err != nil { - return nil, fmt.Errorf("parse idp metadata URL: %w", err) - } - - m, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient, *url) - if err != nil { - return nil, fmt.Errorf("fetch idp metadata: %w", err) - } - - return m, nil -} diff --git a/backend/build/package/Dockerfile b/backend/build/package/Dockerfile deleted file mode 100644 index 66a17768f..000000000 --- a/backend/build/package/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM golang:1.14-alpine as build - -RUN apk add --no-cache \ - make - -COPY . /go/src/github.com/isard-vdi/isard/backend -WORKDIR /go/src/github.com/isard-vdi/isard/backend - -RUN make build - -FROM alpine:3.11.3 -MAINTAINER isard - -COPY --from=build /go/src/github.com/isard-vdi/isard/backend / - -#EXPOSE 8080 - -CMD [ "/backend" ] diff --git a/backend/cfg/cfg.go b/backend/cfg/cfg.go deleted file mode 100644 index 04974d6e5..000000000 --- a/backend/cfg/cfg.go +++ /dev/null @@ -1,57 +0,0 @@ -package cfg - -type Cfg struct { - BackendHost string - Redis Redis - Auth Auth - Isard Isard - Frontend Frontend -} - -type Redis struct { - Host string - Port int - Password string -} - -type Auth struct { - AutoRegistration bool - GitHub AuthGitHub - SAML AuthSAML - Google AuthGoogle -} - -type AuthGitHub struct { - Host, - ID, - Secret string -} - -type AuthSAML struct { - CertPath, - KeyPath, - IdpMetadataPath, - IdpMetadataURL, - Callback, - - AttrID, - AttrUsername, - AttrName, - AttrEmail, - AttrPhoto string -} - -type AuthGoogle struct { - ID, - Secret string -} - -type Isard struct { - Host string - Port int -} - -type Frontend struct { - ShowAdminButton bool `json:"show_admin_button"` - SocialLogins []string `json:"social_logins"` -} diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go deleted file mode 100644 index ff859fbf6..000000000 --- a/backend/cmd/backend/main.go +++ /dev/null @@ -1,155 +0,0 @@ -package main - -import ( - "net/http" - "os" - "strconv" - - "github.com/isard-vdi/isard/backend/api" - "github.com/isard-vdi/isard/backend/auth" - "github.com/isard-vdi/isard/backend/cfg" - "github.com/isard-vdi/isard/backend/env" - "github.com/isard-vdi/isard/backend/isard" - - "github.com/spf13/afero" - "go.uber.org/zap" -) - -var ( - e *env.Env - - logger *zap.Logger - sugar *zap.SugaredLogger -) - -func init() { - logger, _ = zap.NewProduction() - sugar = logger.Sugar() - defer logger.Sync() - - var ( - redisPort, - isardPort int - - err error - ) - - redisPortStr := os.Getenv("BACKEND_REDIS_PORT") - if redisPortStr != "" { - redisPort, err = strconv.Atoi(redisPortStr) - if err != nil { - sugar.Fatalw("invalid redis port", - "err", err, - ) - } - - } else { - redisPort = 6379 - } - - isardPortStr := os.Getenv("BACKEND_ISARD_API_PORT") - if isardPortStr != "" { - isardPort, err = strconv.Atoi(isardPortStr) - if err != nil { - sugar.Fatalw("invalid isard port", - "err", err, - ) - } - - } else { - isardPort = 7039 - } - - autoregistration, err := strconv.ParseBool(os.Getenv("BACKEND_AUTH_AUTOREGISTRATION")) - if err != nil { - sugar.Fatalw("invalid autoregistration value", - "err", err, - ) - } - - var frontendShowAdmin bool - frontendShowAdminEnv := os.Getenv("FRONTEND_SHOW_ADMIN_BTN") - if frontendShowAdminEnv != "" { - frontendShowAdmin, err = strconv.ParseBool(frontendShowAdminEnv) - if err != nil { - sugar.Fatalw("invalid frontend show admin button value", - "err", err, - ) - } - - } - - envAuthGithubHost := os.Getenv("BACKEND_AUTH_GITHUB_HOST") - envAuthGithubID := os.Getenv("BACKEND_AUTH_GITHUB_ID") - envAuthGithubSecret := os.Getenv("BACKEND_AUTH_GITHUB_SECRET") - var frontendSocialLogins []string - if envAuthGithubHost != "" && envAuthGithubID != "" && envAuthGithubSecret != "" { - frontendSocialLogins = append(frontendSocialLogins, "Github") - } - - envAuthGoogleID := os.Getenv("BACKEND_AUTH_GOOGLE_ID") - envAuthGoogleSecret := os.Getenv("BACKEND_AUTH_GOOGLE_SECRET") - if envAuthGoogleID != "" && envAuthGoogleSecret != "" { - frontendSocialLogins = append(frontendSocialLogins, "Google") - } - - e = &env.Env{ - Sugar: sugar, - FS: afero.NewOsFs(), - Cfg: cfg.Cfg{ - BackendHost: os.Getenv("BACKEND_HOST"), - Redis: cfg.Redis{ - Host: os.Getenv("BACKEND_REDIS_HOST"), - Port: redisPort, - Password: os.Getenv("BACKEND_REDIS_PASSWORD"), - }, - Auth: cfg.Auth{ - AutoRegistration: autoregistration, - GitHub: cfg.AuthGitHub{ - Host: envAuthGithubHost, - ID: envAuthGithubID, - Secret: envAuthGithubSecret, - }, - SAML: cfg.AuthSAML{ - CertPath: os.Getenv("BACKEND_AUTH_SAML_CERT_PATH"), - KeyPath: os.Getenv("BACKEND_AUTH_SAML_KEY_PATH"), - IdpMetadataURL: os.Getenv("BACKEND_AUTH_SAML_IDP_URL"), - IdpMetadataPath: os.Getenv("BACKEND_AUTH_SAML_IDP_METADATA_PATH"), - Callback: os.Getenv("BACKEND_AUTH_SAML_CALLBACK_URL"), - AttrID: os.Getenv("BACKEND_AUTH_SAML_ATTR_ID"), - AttrName: os.Getenv("BACKEND_AUTH_SAML_ATTR_NAME"), - }, - Google: cfg.AuthGoogle{ - ID: envAuthGoogleID, - Secret: envAuthGoogleSecret, - }, - }, - Isard: cfg.Isard{ - Host: os.Getenv("BACKEND_ISARD_API_HOST"), - Port: isardPort, - }, - Frontend: cfg.Frontend{ - ShowAdminButton: frontendShowAdmin, - SocialLogins: frontendSocialLogins, - }, - }, - Auth: &env.Auth{}, - } - - auth.Init(e) - e.Isard = isard.New(sugar, e.Cfg.Isard.Host, e.Cfg.Isard.Port) -} - -func main() { - defer logger.Sync() - - a := api.New(e) - - sugar.Info("listening to port :8080") - if err := http.ListenAndServe(":8080", a.Mux); err != nil { - sugar.Fatalw("listen http", - "err", err, - "addr", ":8080", - ) - } -} diff --git a/backend/env/env.go b/backend/env/env.go deleted file mode 100644 index 41e155851..000000000 --- a/backend/env/env.go +++ /dev/null @@ -1,31 +0,0 @@ -package env - -import ( - "sync" - - "github.com/isard-vdi/isard/backend/cfg" - "github.com/isard-vdi/isard/backend/isard" - - "github.com/crewjam/saml/samlsp" - "github.com/go-redis/redis" - "github.com/gorilla/sessions" - "github.com/spf13/afero" - "go.uber.org/zap" -) - -// Env is used for dependency injection -type Env struct { - WG sync.WaitGroup - Sugar *zap.SugaredLogger - - Cfg cfg.Cfg - FS afero.Fs - Redis *redis.Client - Auth *Auth - Isard *isard.Isard -} - -type Auth struct { - SessStore sessions.Store - SAML *samlsp.Middleware -} diff --git a/backend/go.mod b/backend/go.mod deleted file mode 100644 index 5b304b9ae..000000000 --- a/backend/go.mod +++ /dev/null @@ -1,32 +0,0 @@ -module github.com/isard-vdi/isard/backend - -go 1.14 - -require ( - cloud.google.com/go v0.74.0 // indirect - github.com/crewjam/httperr v0.2.0 // indirect - github.com/crewjam/saml v0.4.5 - github.com/go-redis/redis v6.15.9+incompatible - github.com/gorilla/mux v1.8.0 - github.com/gorilla/sessions v1.2.1 - github.com/jonboulle/clockwork v0.2.2 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/nefixestrada/pongo v1.0.0 - github.com/nxadm/tail v1.4.5 // indirect - github.com/onsi/ginkgo v1.14.2 // indirect - github.com/onsi/gomega v1.10.4 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/rbcervilla/redisstore v1.1.0 - github.com/segmentio/ksuid v1.0.3 - github.com/spf13/afero v1.5.1 - go.uber.org/multierr v1.6.0 // indirect - go.uber.org/zap v1.16.0 - golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9 // indirect - golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5 - golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e // indirect - golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect - honnef.co/go/tools v0.1.0 // indirect -) diff --git a/backend/isard/category.go b/backend/isard/category.go deleted file mode 100644 index e23c85cf1..000000000 --- a/backend/isard/category.go +++ /dev/null @@ -1,70 +0,0 @@ -package isard - -import ( - "io" - "io/ioutil" - "net/http" - - "github.com/isard-vdi/isard/backend/pkg/utils" -) - -func (i *Isard) CategoryList(w http.ResponseWriter, r *http.Request) { - rsp, err := http.Get(i.url("categories")) - if err != nil { - i.sugar.Errorw("list categories", - "err", err, - ) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - i.sugar.Errorw("list categories", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - ) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - io.Copy(w, rsp.Body) -} - -func (i *Isard) CategoryLoad(category string, w http.ResponseWriter, r *http.Request) { - rsp, err := http.Get(i.url("category/" + category)) - if err != nil { - i.sugar.Errorw("get category", - "err", err, - "category", category, - ) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - if rsp.StatusCode == http.StatusNotFound { - w.WriteHeader(http.StatusNotFound) - return - } - - b, _ := ioutil.ReadAll(rsp.Body) - - i.sugar.Errorw("get category", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "category", category, - ) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - io.Copy(w, rsp.Body) -} diff --git a/backend/isard/deployment.go b/backend/isard/deployment.go deleted file mode 100644 index 712cd9769..000000000 --- a/backend/isard/deployment.go +++ /dev/null @@ -1,49 +0,0 @@ -package isard - -import ( - "fmt" - "io/ioutil" - "net/http" - - "github.com/isard-vdi/isard/backend/model" - "github.com/isard-vdi/isard/backend/pkg/utils" -) - -func (i *Isard) DeploymentGet(u *model.User, id string) ([]byte, error) { - req, err := http.NewRequest(http.MethodGet, i.url(fmt.Sprintf("user/%s/deployment/%s", u.ID(), id)), http.NoBody) - if err != nil { - i.sugar.Errorw("get deployment: start http request", - "err", err, - "id", id, - ) - - return nil, fmt.Errorf("get deployment: start http request: %w", err) - } - - rsp, err := http.DefaultClient.Do(req) - if err != nil { - i.sugar.Errorw("get deployment", - "err", err, - "id", id, - ) - - return nil, fmt.Errorf("get deployment: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("get deployment", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", id, - ) - - return nil, fmt.Errorf("get deployment: %w", err) - } - - return ioutil.ReadAll(rsp.Body) -} diff --git a/backend/isard/desktop.go b/backend/isard/desktop.go deleted file mode 100644 index cbdd73e02..000000000 --- a/backend/isard/desktop.go +++ /dev/null @@ -1,183 +0,0 @@ -package isard - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "strconv" - - "github.com/isard-vdi/isard/backend/model" - "github.com/isard-vdi/isard/backend/pkg/utils" -) - -func (i *Isard) DesktopCreate(u *model.User, tmpl string, persistent bool) (string, error) { - rsp, err := http.PostForm(i.url("desktop"), url.Values{ - "id": {u.ID()}, - "template": {tmpl}, - "persistent": {strconv.FormatBool(persistent)}, - }) - if err != nil { - i.sugar.Errorw("desktop create", - "err", err, - "usr", u.ID(), - "tmpl", tmpl, - "persistent", persistent, - ) - - return "", fmt.Errorf("desktop create: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("desktop create", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "usr", u.ID(), - "tmpl", tmpl, - "persistent", persistent, - ) - - return "", fmt.Errorf("desktop create: %w", err) - } - - d := &desktopCreateRsp{} - if err := json.NewDecoder(rsp.Body).Decode(d); err != nil { - i.sugar.Errorw("desktop create: unmarshal JSON response", - "err", err, - "usr", u.ID(), - "tmpl", tmpl, - "persistent", persistent, - ) - - return "", fmt.Errorf("desktop create: %w", err) - } - - return d.ID, nil -} - -func (i *Isard) DesktopDelete(id string) error { - req, err := http.NewRequest(http.MethodDelete, i.url("desktop/"+id), http.NoBody) - if err != nil { - i.sugar.Errorw("delete desktop: create http request", - "err", err, - "id", id, - ) - - return fmt.Errorf("delete desktop: create http request: %w", err) - } - - rsp, err := http.DefaultClient.Do(req) - if err != nil { - i.sugar.Errorw("delete desktop", - "err", err, - "id", id, - ) - - return fmt.Errorf("delete desktop: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("delete desktop", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", id, - ) - - return fmt.Errorf("delete desktop: %w", err) - } - - return nil -} - -func (i *Isard) DesktopStart(id string) error { - req, err := http.NewRequest(http.MethodGet, i.url("desktop/start/"+id), http.NoBody) - if err != nil { - i.sugar.Errorw("start desktop: start http request", - "err", err, - "id", id, - ) - - return fmt.Errorf("start desktop: start http request: %w", err) - } - - rsp, err := http.DefaultClient.Do(req) - if err != nil { - i.sugar.Errorw("start desktop", - "err", err, - "id", id, - ) - - return fmt.Errorf("start desktop: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("start desktop", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", id, - ) - - return fmt.Errorf("start desktop: %w", err) - } - - return nil -} - -func (i *Isard) DesktopStop(id string) error { - req, err := http.NewRequest(http.MethodGet, i.url("desktop/stop/"+id), http.NoBody) - if err != nil { - i.sugar.Errorw("stop desktop: stop http request", - "err", err, - "id", id, - ) - - return fmt.Errorf("stop desktop: stop http request: %w", err) - } - - rsp, err := http.DefaultClient.Do(req) - if err != nil { - i.sugar.Errorw("stop desktop", - "err", err, - "id", id, - ) - - return fmt.Errorf("stop desktop: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("stop desktop", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", id, - ) - - return fmt.Errorf("stop desktop: %w", err) - } - - return nil -} - -type desktopCreateRsp struct { - ID string `json:"id,omitempty"` -} diff --git a/backend/isard/isard.go b/backend/isard/isard.go deleted file mode 100644 index 233faa4b7..000000000 --- a/backend/isard/isard.go +++ /dev/null @@ -1,89 +0,0 @@ -package isard - -import ( - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "path" - - "github.com/isard-vdi/isard/backend/pkg/utils" - "github.com/isard-vdi/isard/backend/model" - "go.uber.org/zap" -) - -// ErrUnknownViewerType is an unknown viewer -var ErrUnknownViewerType = errors.New("unknown viewer type") - -const apiEndpoint = "/api/v2" - -// Isard is the Isard API client -type Isard struct { - sugar *zap.SugaredLogger - host string - port int -} - -// New initializes the Isard client -func New(sugar *zap.SugaredLogger, host string, port int) *Isard { - return &Isard{sugar, host, port} -} - -func (i *Isard) url(endpoint string) string { - u := &url.URL{ - Scheme: "http", - Host: fmt.Sprintf("%s:%d", i.host, i.port), - Path: path.Join(apiEndpoint, endpoint), - } - - return u.String() -} - -type loginRsp struct { - ID string `json:"id,omitempty"` - Success bool `json:"success,omitempty"` -} - -func (i *Isard) Login(usr *model.User, pwd string) error { - rsp, err := http.PostForm(i.url("login"), url.Values{ - "id": {usr.ID()}, - "passwd": {pwd}, - }) - if err != nil { - i.sugar.Errorw("login", - "err", err, - "usr", usr.ID(), - ) - - return fmt.Errorf("login: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - defer rsp.Body.Close() - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("login", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "usr", usr.ID(), - ) - - return fmt.Errorf("login: %w", err) - } - - login := loginRsp{} - if err := json.NewDecoder(rsp.Body).Decode(&login); err != nil { - i.sugar.Errorw("login: decode JSON response", - "err", err, - ) - - return fmt.Errorf("login: decode JSON response: %w", err) - } - usr.LoadFromID(login.ID) - return nil -} diff --git a/backend/isard/register.go b/backend/isard/register.go deleted file mode 100644 index 37774011a..000000000 --- a/backend/isard/register.go +++ /dev/null @@ -1,72 +0,0 @@ -package isard - -import ( - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - "net/url" - - "github.com/isard-vdi/isard/backend/model" - "github.com/isard-vdi/isard/backend/pkg/utils" -) - -type registerRsp struct { - Role string - Category string - Group string -} - -func (i *Isard) CheckRegistrationCode(u *model.User, code string) error { - rsp, err := http.PostForm(i.url("register"), url.Values{"code": {code}, "email": {u.Email}}) - if err != nil { - i.sugar.Errorw("check registration code", - "err", err, - "id", u.ID(), - "code", code, - ) - - return fmt.Errorf("check registration code: %w", err) - } - - if rsp.StatusCode != 200 { - if rsp.StatusCode == http.StatusNotFound { - return errors.New("invalid registration code") - } - if rsp.StatusCode == http.StatusForbidden { - return errors.New("not allowed") - } - - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("check registration code", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", u.ID(), - ) - - return fmt.Errorf("check registration code: %w", err) - } - - props := ®isterRsp{} - if err := json.NewDecoder(rsp.Body).Decode(props); err != nil { - i.sugar.Errorw("check registration code: decode JSON response", - "err", err, - "id", u.ID(), - ) - - return fmt.Errorf("check registration code: decode JSON response: %w", err) - } - - if u.Category != props.Category { - return errors.New("check registration code: registration code from other category") - } - - u.Role = props.Role - u.Group = props.Group - - return nil -} diff --git a/backend/isard/user.go b/backend/isard/user.go deleted file mode 100644 index 54985c3a5..000000000 --- a/backend/isard/user.go +++ /dev/null @@ -1,283 +0,0 @@ -package isard - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "strings" - - "github.com/isard-vdi/isard/backend/model" - "github.com/isard-vdi/isard/backend/pkg/utils" -) - -func (i *Isard) UserLoad(u *model.User) error { - rsp, err := http.Get(i.url("user/" + u.ID())) - if err != nil { - i.sugar.Errorw("get user", - "err", err, - "id", u.ID(), - ) - return fmt.Errorf("get user: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - if rsp.StatusCode == http.StatusNotFound { - return model.ErrNotFound - } - - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("get user", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", u.ID(), - ) - - return fmt.Errorf("get user: %w", err) - } - - if err := json.NewDecoder(rsp.Body).Decode(&u); err != nil { - i.sugar.Errorw("get user: decode JSON response", - "err", err, - ) - return fmt.Errorf("get user: decode JSON response: %w", err) - } - - if err := i.UserTemplates(u); err != nil { - return err - } - - if err := i.UserDesktops(u); err != nil { - return err - } - - if err := i.UserDeployments(u); err != nil { - return err - } - - return nil -} - -func (i *Isard) UserUpdate(u *model.User) error { - if u.Name == "" || u.Email == "" { - return nil - } - params := url.Values{ - "name": {u.Name}, - "email": {u.Email}, - } - if u.Photo != "" { - params.Add("photo", u.Photo) - } - req, err := http.NewRequest(http.MethodPut, i.url("user/"+u.ID()), strings.NewReader(params.Encode())) - if err != nil { - i.sugar.Errorw("update user: create request", - "err", err, - "id", u.ID(), - ) - - return fmt.Errorf("update user: create http request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - rsp, err := http.DefaultClient.Do(req) - if err != nil { - i.sugar.Errorw("update user", - "err", err, - "id", u.ID(), - ) - return fmt.Errorf("update user: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("update user", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", u.ID(), - ) - - return fmt.Errorf("update user: %w", err) - } - - return nil -} - -func (i *Isard) UserDesktops(u *model.User) error { - rsp, err := http.Get(i.url("user/" + u.ID() + "/desktops")) - if err != nil { - i.sugar.Errorw("get user desktops", - "err", err, - "id", u.ID(), - ) - return fmt.Errorf("get user desktops: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("get user desktops", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", u.ID(), - ) - - return fmt.Errorf("get user desktops: %w", err) - } - - if err := json.NewDecoder(rsp.Body).Decode(&u.Desktops); err != nil { - i.sugar.Errorw("get user desktops: decode JSON response", - "err", err, - ) - - return fmt.Errorf("get user desktops: decode JSON response: %w", err) - } - - return nil -} - -type userTemplateRsp struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - Image string `json:"image,omitempty"` -} - -func (i *Isard) UserTemplates(u *model.User) error { - rsp, err := http.Get(i.url("user/" + u.ID() + "/templates")) - if err != nil { - i.sugar.Errorw("get user templates", - "err", err, - "id", u.ID(), - ) - return fmt.Errorf("get user templates: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("get user templates", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", u.ID(), - ) - - return fmt.Errorf("get user templates: %w", err) - } - - templates := []userTemplateRsp{} - if err := json.NewDecoder(rsp.Body).Decode(&templates); err != nil { - i.sugar.Errorw("get user templates: decode JSON response", - "err", err, - ) - - return fmt.Errorf("get user templates: decode JSON response: %w", err) - } - - for _, t := range templates { - u.Templates = append(u.Templates, model.Template{ - ID: t.ID, - Name: t.Name, - Description: t.Description, - Icon: t.Icon, - Image: t.Image, - }) - } - - return nil -} - -func (i *Isard) UserRegister(u *model.User) error { - rsp, err := http.PostForm(i.url("user"), url.Values{ - "provider": {u.Provider}, - "user_uid": {u.UID}, - "user_username": {u.Username}, - "role": {u.Role}, - "category": {u.Category}, - "group": {u.Group}, - }) - - if err != nil { - i.sugar.Errorw("register user", - "err", err, - "id", u.ID(), - "role", u.Role, - "category", u.Category, - "group", u.Group, - ) - - return fmt.Errorf("register user: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("register user", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", u.ID(), - "role", u.Role, - "category", u.Category, - "group", u.Group, - ) - - return fmt.Errorf("register user: %w", err) - } - - return nil -} - -func (i *Isard) UserDeployments(u *model.User) error { - rsp, err := http.Get(i.url("user/" + u.ID() + "/deployments")) - if err != nil { - i.sugar.Errorw("get user deployments", - "err", err, - "id", u.ID(), - ) - return fmt.Errorf("get user deployments: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("get user deployments", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "id", u.ID(), - ) - - return fmt.Errorf("get user deployments: %w", err) - } - - if err := json.NewDecoder(rsp.Body).Decode(&u.Deployments); err != nil { - i.sugar.Errorw("get user deployments: decode JSON response", - "err", err, - ) - - return fmt.Errorf("get user deployments: decode JSON response: %w", err) - } - - return nil -} diff --git a/backend/isard/viewer.go b/backend/isard/viewer.go deleted file mode 100644 index 2d5e70085..000000000 --- a/backend/isard/viewer.go +++ /dev/null @@ -1,51 +0,0 @@ -package isard - -import ( - "fmt" - "io/ioutil" - "net/http" - - "github.com/isard-vdi/isard/backend/pkg/utils" -) - -var viewerTypeTranslation = map[string]string{ - "spice": "spice-client", - "rdp": "rdp-client", - "browser": "vnc-html5", - "rdp-html5": "rdp-html5", -} - -func (i *Isard) Viewer(id string, viewerType string) ([]byte, error) { - viewer, found := viewerTypeTranslation[viewerType] - if !found { - return nil, ErrUnknownViewerType - } - - rsp, err := http.Get(i.url(fmt.Sprintf("desktop/%s/viewer/%s", id, viewer))) - if err != nil { - i.sugar.Errorw("get viewer", - "err", err, - "type", viewer, - "id", id, - ) - return nil, fmt.Errorf("get viewer: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("get viewer", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - "type", viewer, - "id", id, - ) - - return nil, fmt.Errorf("get viewer: %w", err) - } - - return ioutil.ReadAll(rsp.Body) -} diff --git a/backend/isard/vpn.go b/backend/isard/vpn.go deleted file mode 100644 index 0f1e5ac88..000000000 --- a/backend/isard/vpn.go +++ /dev/null @@ -1,36 +0,0 @@ -package isard - -import ( - "fmt" - "io/ioutil" - "net/http" - - "github.com/isard-vdi/isard/backend/model" - "github.com/isard-vdi/isard/backend/pkg/utils" -) - -func (i *Isard) Vpn(u *model.User) ([]byte, error) { - rsp, err := http.Get(i.url(fmt.Sprintf("user/%s/vpn/config", u.ID()))) - if err != nil { - i.sugar.Errorw("get vpn", - "err", err, - ) - return nil, fmt.Errorf("get viewer: %w", err) - } - - defer rsp.Body.Close() - if rsp.StatusCode != http.StatusOK { - b, _ := ioutil.ReadAll(rsp.Body) - - err = utils.NewHTTPCodeErr(rsp.StatusCode) - i.sugar.Errorw("get vpn", - "err", err, - "code", rsp.StatusCode, - "body", string(b), - ) - - return nil, fmt.Errorf("get vpn: %w", err) - } - - return ioutil.ReadAll(rsp.Body) -} diff --git a/backend/isardAdmin/isardAdmin.go b/backend/isardAdmin/isardAdmin.go deleted file mode 100644 index 88f90d2c8..000000000 --- a/backend/isardAdmin/isardAdmin.go +++ /dev/null @@ -1,29 +0,0 @@ -package isardAdmin - -import "net/http" - -const LogoutURL = "http://isard-webapp:5000/isard-admin/logout/remote" - -func Logout(w http.ResponseWriter, r *http.Request) error { - req, err := http.NewRequest("GET", LogoutURL, nil) - if err != nil { - return err - } - for _, c := range r.Cookies() { - req.AddCookie(c) - } - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - for _, c := range resp.Cookies() { - http.SetCookie(w, c) - } - return nil -} diff --git a/backend/model/desktop.go b/backend/model/desktop.go deleted file mode 100644 index 3e90dbf22..000000000 --- a/backend/model/desktop.go +++ /dev/null @@ -1,14 +0,0 @@ -package model - -type Desktop struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - State string `json:"state,omitempty"` - Type string `json:"type,omitempty"` - Template string `json:"template,omitempty"` - Viewers []string `json:"viewers,omitempty"` - Icon string `json:"icon,omitempty"` - Image string `json:"image,omitempty"` - IP string `json:"ip,omitempty"` -} diff --git a/backend/model/errors.go b/backend/model/errors.go deleted file mode 100644 index b098840de..000000000 --- a/backend/model/errors.go +++ /dev/null @@ -1,5 +0,0 @@ -package model - -import "errors" - -var ErrNotFound = errors.New("not found") diff --git a/backend/model/template.go b/backend/model/template.go deleted file mode 100644 index a40163c72..000000000 --- a/backend/model/template.go +++ /dev/null @@ -1,9 +0,0 @@ -package model - -type Template struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - Image string `json:"image,omitempty"` -} diff --git a/backend/model/user.go b/backend/model/user.go deleted file mode 100644 index 25e32acec..000000000 --- a/backend/model/user.go +++ /dev/null @@ -1,46 +0,0 @@ -package model - -import ( - "strings" -) - -const userIDSFieldSeparator = "-" - -// User is an user of IsardVDI -type User struct { - UID string - Username string - Provider string - - Category string - Role string `json:"role"` - Group string `json:"group"` - - Desktops []Desktop - Templates []Template - Deployments []UserDeployment - - Name string `json:"name"` - Email string `json:"email"` - Photo string `json:"photo"` -} - -type UserDeployment struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - TotalDesktops int `json:"totalDesktops,omitempty"` - StartedDesktops int `json:"startedDesktops"` -} - -func (u *User) ID() string { - return strings.Join([]string{u.Provider, u.Category, u.UID, u.Username}, userIDSFieldSeparator) -} - -func (u *User) LoadFromID(id string) { - parts := strings.Split(id, userIDSFieldSeparator) - - u.Provider = parts[0] - u.Category = parts[1] - u.UID = parts[2] - u.Username = strings.Join(parts[3:], userIDSFieldSeparator) -} diff --git a/backend/pkg/utils/errors.go b/backend/pkg/utils/errors.go deleted file mode 100644 index 088adb2d3..000000000 --- a/backend/pkg/utils/errors.go +++ /dev/null @@ -1,19 +0,0 @@ -package utils - -import ( - "fmt" -) - -// ErrHTTPCode is an error that gets returned when a call to an API is not the HTTP Code that the program was expecting -type ErrHTTPCode struct { - Code int -} - -func (h *ErrHTTPCode) Error() string { - return fmt.Sprintf("http code: %d", h.Code) -} - -// NewHTTPCodeErr creates a new HTTPCodeErr -func NewHTTPCodeErr(code int) error { - return &ErrHTTPCode{code} -} diff --git a/build.sh b/build.sh index 2bd45d163..02caca784 100755 --- a/build.sh +++ b/build.sh @@ -112,7 +112,7 @@ flavour "" \ grafana \ stats \ api \ - backend \ + authentication \ vpn \ guac \ @@ -156,7 +156,7 @@ flavour web \ grafana \ stats \ api \ - backend \ + authentication \ flavour stats \ network \ diff --git a/docker-compose-parts/api.devel.yml b/docker-compose-parts/api.devel.yml index 6513eb50d..0772bc183 100644 --- a/docker-compose-parts/api.devel.yml +++ b/docker-compose-parts/api.devel.yml @@ -5,11 +5,13 @@ services: #image: isard/webapp:${TAG:-latest} ports: - "7039:7039" + - "5000:5000" restart: unless-stopped volumes: - /etc/localtime:/etc/localtime:ro - ${BUILD_ROOT_PATH}/api/src:/api - - ${BUILD_ROOT_PATH}/webapp/webapp/lib:/api/lib:ro + - ${BUILD_ROOT_PATH}/api/srcv2:/apiv2 + # - ${BUILD_ROOT_PATH}/webapp/webapp/lib:/api/lib:ro build: context: ${BUILD_ROOT_PATH} dockerfile: api/docker/Dockerfile diff --git a/docker-compose-parts/authentication.build.yml b/docker-compose-parts/authentication.build.yml new file mode 100644 index 000000000..7c9e39ef8 --- /dev/null +++ b/docker-compose-parts/authentication.build.yml @@ -0,0 +1,6 @@ +version: '3.5' +services: + isard-authentication: + build: + context: ${BUILD_ROOT_PATH} + dockerfile: authentication/build/package/Dockerfile diff --git a/docker-compose-parts/authentication.yml b/docker-compose-parts/authentication.yml new file mode 100644 index 000000000..06834590f --- /dev/null +++ b/docker-compose-parts/authentication.yml @@ -0,0 +1,17 @@ +version: '3.5' +services: + isard-authentication: + container_name: isard-authentication + image: ${DOCKER_IMAGE_PREFIX}authentication:${DOCKER_IMAGE_TAG:-latest} + logging: + options: + max-size: "100m" + networks: + isard-network: + ipv4_address: ${DOCKER_NET}.11 + restart: unless-stopped + volumes: + - "/opt/isard/backend/keys:/keys" + - "/opt/isard/backend/metadata:/metadata" + env_file: + - .env diff --git a/docker-compose-parts/backend.build.yml b/docker-compose-parts/backend.build.yml deleted file mode 100644 index 060df803d..000000000 --- a/docker-compose-parts/backend.build.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: '3.5' -services: - isard-backend: - build: - context: ${BUILD_ROOT_PATH}/backend - dockerfile: build/package/Dockerfile diff --git a/docker-compose-parts/backend.yml b/docker-compose-parts/backend.yml deleted file mode 100644 index 97cd34a24..000000000 --- a/docker-compose-parts/backend.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: '3.5' -services: - isard-backend: - container_name: isard-backend - image: ${DOCKER_IMAGE_PREFIX}backend:${DOCKER_IMAGE_TAG:-latest} - logging: - options: - max-size: "100m" - # ports: - # - "127.0.0.1:1312:1312" - networks: - isard-network: - ipv4_address: ${DOCKER_NET}.11 - restart: unless-stopped - volumes: - - "/opt/isard/backend/keys:/keys" - - "/opt/isard/backend/metadata:/metadata" - env_file: - - .env - - isard-redis: - container_name: isard-redis - image: redis:6-alpine - logging: - options: - max-size: "100m" - networks: - isard-network: - ipv4_address: ${DOCKER_NET}.12 - restart: unless-stopped - volumes: - - "/opt/isard/redis:/data" diff --git a/docker-compose-parts/guac.yml b/docker-compose-parts/guac.yml index d3cc3d930..88d4a9fd0 100644 --- a/docker-compose-parts/guac.yml +++ b/docker-compose-parts/guac.yml @@ -5,7 +5,9 @@ services: image: ${DOCKER_IMAGE_PREFIX}guac:${DOCKER_IMAGE_TAG-latest} environment: GUACD_ADDR: isard-vpn:4822 - BACKEND_HOST: ${BACKEND_HOST} + BACKEND_HOST: ${BACKEND_HOST} + API_ISARDVDI_SECRET: ${API_ISARDVDI_SECRET} + GUACD_BACKEND_HOST: isard-api:5000 networks: isard-network: ipv4_address: ${DOCKER_NET}.16 diff --git a/docker/portal/haproxy.conf b/docker/portal/haproxy.conf index fa08b0d3e..05e099a8b 100644 --- a/docker/portal/haproxy.conf +++ b/docker/portal/haproxy.conf @@ -26,11 +26,12 @@ global frontend fe_proxy_squid bind 0.0.0.0:80 - mode tcp + mode tcp + option tcplog tcp-request inspect-delay 10s tcp-request content accept if { ssl_fc } - use_backend redirecthttps-backend if !{ method CONNECT } tcp-request content accept if !HTTP + use_backend redirecthttps-backend if !{ method CONNECT } default_backend be_isard-squid backend redirecthttps-backend @@ -62,15 +63,25 @@ global http-request return status 503 if is_api !{ srv_is_up(be_isard-engine/engine) } - # VIDEO ENDPOINTS + # GUACAMOLE ENDPOINTS use_backend be_isard-guacamole if is_websocket is_guacamole_ws use_backend be_isard-guacamole if is_guacamole_http - use_backend be_isard-webapp if is_websocket { path_beg /isard-admin/ } + + # AUTHENTICATION ENDPOINTS + use_backend be_isard-authentication if { path_beg /authentication } + + # API ENDPOINTS + use_backend be_isard-api if { path_beg /debug/api/api/v2 } + use_backend be_isard-apiv3 if { path_beg /api/v3 } + use_backend be_isard-apiv3 if is_websocket { path_beg /api/v3/socket.io } + + # WEBAPP ENDPOINTS + use_backend be_isard-webapp if { path_beg /socket.io } + use_backend be_isard-webapp if { path_beg /isard-admin } or { path_beg /isard-admin/ } + + # DEFAULT WEBSOCKETS: HTML5 ENDPOINT use_backend be_isard-websockify if is_websocket !{ path_beg /sockjs-node/ } - use_backend be_isard-backend if is_api - use_backend be_isard-backend if { path_beg /callback } - # Jumper HTML5 viewer use_backend be_isard-api-viewer if { path_beg /vw } @@ -78,10 +89,6 @@ global use_backend be_isard-db if { path_beg /debug/db } use_backend be_isard-video if { path_beg /debug/video } use_backend be_isard-grafana if { path_beg /monitor } or { path_beg /monitor/ } - use_backend be_isard-api if { path_beg /debug/api } - - #use_backend be_isard-webapp if { path_beg /socket.io } - use_backend be_isard-webapp if { path_beg /isard-admin } or { path_beg /isard-admin/ } # develop backends use_backend be_isard-frontend-dev if { env(DEVELOPMENT) -m str true } !{ path_beg /viewer/ } !{ path_beg /custom/ } @@ -107,8 +114,9 @@ global option abortonclose server squid isard-squid:8080 check port 8080 inter 5s rise 2 fall 3 resolvers mydns init-addr none - backend be_isard-backend - server backend isard-backend:8080 maxconn 1000 check port 8080 inter 5s rise 2 fall 3 resolvers mydns init-addr none + backend be_isard-authentication + http-request replace-path /authentication/(.*) /\1 + server authentication isard-authentication:1313 maxconn 1000 check port 1313 inter 5s rise 2 fall 3 resolvers mydns init-addr none backend be_isard-static server static isard-static:80 maxconn 1000 check port 80 inter 5s rise 2 fall 3 resolvers mydns init-addr none @@ -151,17 +159,26 @@ global backend be_isard-webapp timeout queue 600s timeout server 600s - timeout connect 600s + timeout connect 600s server static "${WEBAPP_HOST}":5000 maxconn 100 check port 5000 inter 5s rise 2 fall 3 resolvers mydns init-addr none server isard-static isard-static backup backend be_isard-api acl authorized http_auth(AuthUsers) http-request auth realm AuthUsers unless authorized + http-request replace-path /debug/api/(.*) /\1 http-request del-header Authorization server isard-api isard-api:7039 maxconn 10 check port 7039 inter 5s rise 2 fall 3 resolvers mydns init-addr none + backend be_isard-apiv3 + option forwardfor + timeout queue 600s + timeout server 600s + timeout connect 600s + http-response set-header Access-Control-Allow-Origin "*" + server isard-api isard-api:5000 maxconn 10 check port 5000 inter 5s rise 2 fall 3 resolvers mydns init-addr none + backend letsencrypt-backend server letsencrypt 127.0.0.1:8080 diff --git a/docker/static/noVNC/src b/docker/static/noVNC/src index dbd519558..7485e82b7 160000 --- a/docker/static/noVNC/src +++ b/docker/static/noVNC/src @@ -1 +1 @@ -Subproject commit dbd519558c1efd98afd08126594efc334eb88dd6 +Subproject commit 7485e82b72d4d1356d95ecca2d109cbf49908b9d diff --git a/engine/engine/initdb/populate.py b/engine/engine/initdb/populate.py index 65cda039c..3547f0547 100644 --- a/engine/engine/initdb/populate.py +++ b/engine/engine/initdb/populate.py @@ -67,7 +67,7 @@ def check_integrity(self,commit=True): 'boots','hypervisors_events','hypervisors_status','hypervisors_status_history', 'disk_operations','hosts_viewers','places', 'scheduler_jobs','backups','config','engine', - 'qos_net','qos_disk','remotevpn','deployments', + 'qos_net','qos_disk','remotevpn','deployments','secrets' ] tables_to_create=list(set(newtables) - set(dbtables)) d = {k:v for v,k in enumerate(newtables)} @@ -300,6 +300,17 @@ def deployments(self): self.index_create('deployments',['user']) return True + ''' + SECRETS + ''' + + def secrets(self): + if not r.table_list().contains('secrets').run(): + log.info("Table secrets not found, creating...") + r.table_create('secrets', primary_key="id").run() + # self.index_create('deployments',['user']) + return True + # {'allowed': {'groups': [], 'users': False}, # 'description': '', # 'forced_hyp': ['false'], diff --git a/frontend/package.json b/frontend/package.json index 94b73065e..b0e65097c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,20 +14,23 @@ "@fortawesome/free-brands-svg-icons": "^5.13.0", "@fortawesome/free-solid-svg-icons": "^5.13.0", "@fortawesome/vue-fontawesome": "^0.1.9", + "@novnc/novnc": "^1.2.0", "axios": "^0.21.1", "bootstrap": "^4.4.1", "bootstrap-vue": "^2.9.0", "core-js": "^3.6.4", + "guacamole-common-js": "^1.3.0", "nightwatch": "^1.3.4", "snotify": "^1.0.0", + "socket.io-client": "^4.1.3", "tiny-cookie": "^2.3.2", "vue": "^2.6.11", "vue-fontawesome": "0.0.2", "vue-i18n": "^8.22.1", "vue-router": "^3.1.5", "vue-snotify": "^3.2.1", - "vuex": "^3.1.2", - "guacamole-common-js": "^1.3.0" + "vue-socket.io-extended": "^4.2.0", + "vuex": "^3.1.2" }, "devDependencies": { "@babel/preset-stage-2": "^7.8.3", diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 3a4a2a8d9d2bbe7203dd601b61595b08de5336df..9d27cd143931f08f7c2b079e3f8f2dfa5bf968f7 100644 GIT binary patch literal 24838 zcmd^H33wDm8to7uKtfJ#goHpsI0XU(6$pn5A|h9mTk>(qjRFFqlHkE1h#adfc%TRv z5CK6sBtjwq5`wu231>i9K~bZkgv?CWzSlE7I~it@A(_cC+5h|A)O2-K{q?G*D(m!*;A5w^w3|2^pd2DjU_1r zac~PM=>6eHNLIRmutW*sh5Ms`ZNNgn1+Y~D5%6>1HXwjafUWWU5cVrWJjNxW0b6A< z0%`#zZsp3A^J}=fclD@Q)0X&E5onJZ*VWZkj2s*JXMA#M#M<>ANQoKQC8gnm0I7^$ zxk?o=V$4|iaN;SEd^-0^+S!XUFI>Ob>gHbszCs9zupq3JM8x1I;6>n*0&UxYIxeFJ@KRaz&)2Xv&S%|Kb@%lP->xpf9zLpQ4NEYKKPZf3R*B4EL zf<)xRXA5S`o|`m$^q3B#Bga{aPrsi120VE)_E?e_Gj6=7-=Lvr*}9GRWZRBha2WH8 zKL0LO^3V8{qL(cH=f=;ri*}(MMc@8Uhy{xlpU=H|en$x>^rn@s2xu!+;pz2v7&O z7a`e^z#5=;Nf8UeB`F`si_#+wZk*eHk%#|z3ILl_g=sWtnfcly;RWRc?BC)C}|Vh zmC$cLGj2lu@wCjx_QxHyrQf!efdRNtXqtYdN|nSDg9huqJ${mXb1KG!P8=6(7#l3< zc*Znsj8$UTs4+}iPRh&?NRv7)V*Ge19DdxeY0K^fi=xBcSh1?))b$}|di(fj zH}BXbCug4(Z>(G;e0=?s_T+-JhYlYhA4*8Pm6CIQL*~UR?J@pU&AR%Nt>sS*ex1U) z?BjU4W#^Y7sCjcS?YTM1xY!=!z-u?a{985RaIdryfQaV+)^i`g9>Pk16`_pfE8zYc$OOtdHX;c{n07zl zpTG_v0hkHcQ)pAMy>Luqzg-QuClRj+Np=;I7APRxgp{uW*ZER7;BQ0$t_x7jS{HpB zEy5DA0b;5CTI&TrfP-NG&;u~qVX&S`X@?<|B0`(A9WVlT4rmILhxkItSYRK(cEfh5 z1vs`jQe2RDH{e^Khz)>i85jdNN~n7na1~I-A~Tn^;9$T}@&ppT4p`Dnzu$mPfFp$M zDg&^loBk<4Ex_Kwa%cZkoNnG*YH!7c^qRrF1X$HywQAMtu$kL?dDAqqyKp>V+qS5m z>md`SOuY&FuIJ&zZ`|#%5l6~;LSxpJ-)y=0kquzd&N=2p$v zMzOvqH*e8W*R4lS@ksY@(Yoz}i^F^MmRwzFs@;>cMQ9cr)6g9{mWa6;=4sHq=bOU< z>v96mzfmvNwB&(9BXl2bj*(%T7amK^Oh;X;!(1Q~HosJ>cAa}tj=;?DN7RosG#&L4 zyAQ--9@oXFm-juryyS(^uj!zZ^QJU}8xPdQ`ko7HYy>H_gre*(uUz+zIEDEb+PzZ8 zb?em=SeFx+t6|RPDq}5Fhq5UIKEgBL57?zI;JPN}9Fw;0+VdCYWn6caIWM0yZH7Rf zCnAQA6j&o<-Dtq~ZRqu&?v&KDQWG|f7#tEJVXj;sbJ%R8E8hQfs}AdznAiC!?cB%5 zS741(AaC1n-;25(Ain(v4oW%teY(KhSH?Q54my)+^K3Lk%RAm0a^Wgbu#;)R!9i6UnQaaZQ{;J@VHcgNY+{fW8%Lr}-HpaP5q zg{X@GHEdi@WhpjIl84^t=iPn3-}wL{x^W~mdQ{^-Nf8-R!-Z5 z@8PJMbCE$ndCF5zb_3wMj7vZu;7F;2#M=Om0WSjVTz3LjfHeT`J6h^O!4zO4@Foxo zunzbvmY1;J`vIJ9Oa$y=^oxRUA@YD|K+;2otCgZm5H1QNFF+!q2u!+F2w8GNTra@i zkVX{JGhi9qL<92SwgZs1h!#S`3&C}Y9zZl;T&IACoz^Lks$GQkD9fMg9&{_ZkYgag zac%*?et!?ZImuX{A`0ucArJ|C0bB;SzQlgk6kJ!VXwnFT76PXMuBVyuHUGZ__gPjI zL8^lU*KgASt7{#&XIchaQ+5TMT^fS`=XbXOQ+_4=-;VI*G3G*z!-V#00#FiHOZPu? zSgy(^Va{LpzQqzZTl|)kPc|K*%8dKj?A%NGAX5x<)glBYeh zu8iDSJ{%wG0nR3ak>Ni8Tk5#DFzoxBt?%c%p`&)-j%klH1aLNCe^ruAS6rQ|_mYqf z%hH*|8=3tMP&t&=)gg`P&P3dhSFRWQ1(cSz5)b>OGa5e7r~%g9ovjGgmX$l+=ZM8i z-@KH4`C1#S@jDZLUu5=F8TrdtbJgIz4%fcdV~xF3`_leLg%%3Ezm|o6$FMG%eMgRJ z4evYu1piTq8K)iguEEabGz{sLy5GmUC(UQO_VB%y@m(+Y=K;L{XDEXOrLB9s$4-{&UFEL2H^Bb|zz2|$@J!r?vf*C%WB;(+ zt54s;J%u2`>j2EU@L!e$2(39-tQ{!`N5|+-sbo2AUyZJ zuK`8_U4gbh@QL(nulST~DIv`v?}bZha$Kji$?n14;?iYA8p+xaMnOfo%OsJ z`Ano3^7L?l{r8&TqetJKHfxsVwdKn-pTxvyV-pezlQJ@9?2n6+E?m1-T%PTDuLlyd zYWwq_J6(0nYmvH|U*Xr-eFZL12FD-tp*mh6eM{r&hdyhn5vu4Wq8cS)ZeynRRZ*MKm zc^2ZV<(=uz&(X|#c~Rlqg)eJ(?i6F|RkWEP?rADV84q2YyB8^g;N~I4);+n$?B0vL zW_?PmZ}P_86b;Va-1+qLU7EO)DQr{d0}Xv+_F0zmHO!$l;v8yC%qPl`zSIQD&YJVb znObYMt+JmJ=bM!}H;-q2jdcMX&gc~4`GC!&?;{M)6Z@3Q0Qch!1AHh`|AJ8sey;g&=-{-t<8 zJU{=%BK5{Lk3LD+Ps;n;-^#tU(`U~WkrO6~#sLAwGGSkbzP!++{AllP0|Ef736#5y zdl%7SJi22K_788aUZ*v!JMLMJoH)rizV$?(&vOObZ_IG`)`4i$*k37w;E)!YuMdBx zHJr!Qg7XHzm)w&w|L4znG1T?9YSpeC`EktW`6_?bJ-$gHx9bpUTqkOa{p|e41kVE# z+rIot`CbH4kkNKb+x^Gj?M?2^AWfP!lhF73;OuY`zr$e|rxg1R_In}u0ZKhLgKax1 za-3q*Bc9@i?`UXue04w>=fL?JpuV$kH>n?QJm1MUi*20ev+qM2*VOd%G`0b?98BHr zJ$q^J%?{ei7l01r?u7h3!0Fh;snZqv4Ea~u0nQcZTC{4VG39IiA2t))KG!;?BsnK{ zP*V0Tem#5sLS^1g{se89NBj2UGhpoN&Hl?cGp)lpgZ&8asB;F)pF_p4Pk;UF1x5MO z_JJMy6O>!r3&r`;lDFReE%U;a0pO4IPUqrR|U&eeq z*b6_l4W|~vH!%7=Gk&7!GjJ5#Jx%!c1xfCAF>M2>xaK?MiXff=fq@6NeDS4X|AF@c zayQ}M2PDn;UxHubia=if*$`2y*IPS)O}~1%3xu7D+%wu>JV_vVSwRl`u>8IETa4Ujz0*PfnlmR>7-UP^#HqIh|?@iV7{m1dYB8_`&13Uw@5unj~0XPOM z1-bwO0s65#IOcZ)Dx$c86uC_Wh5*BX_W{;BZQlgo5uhRq&vTCj_5qgw_WkSwX94wq z{}=HBQEtrO{Dx;H?mNNSqwJ3#NS?pqC|?`EKbc=1fba_ilzfAmXK>^ARRTz~!No_O zd5IQY#6__3Xv<}Ox7!|8A0`Wvp@46dueRT4lriC;*k%dg2OZj_lDZn!pa zp;ytQxCl>|33GHJQMw3D9!wS=RZ!H>RrG_ek{`JGa#8uKZk(QnFuFaWlyu@ERX1F@ z&Wkj-d3rbB;QnrK?-*PWr@w!f8yzJSQC&%Yy;7fT=-1zlV<|!M_1PErBBmnvq9x=% D%kFY@ literal 5430 zcmcJTZ)jCz9LLWt<02wsT*OvHT`n;shKK}3Zzg*&B8G^Fhy=-qF?_KiB1EKJDn>+H zhKvy*HAV~x5s6-ej5bWBh%E7iFCyHC5Q&K4i=27c)8~8d`CXo#oqO&*H~PWH=lB2j zJm)++8%9xAl#6`Z?@*Yma#sZMftXS zpSIs~`xwr{Rk#Vupl|d790&FDCA9sR zbe$uVAsqhfDz@h8d*+LJt6sme`r#ssLYjp8=?jt-FN4j0i2gd5g!QuZoqiSe!3sPF zCVsy3kWzy03O>!(Z1oLm%+}Ayr?3IG-&J(|zOWpB#l7wQ49Mw;^8bXtVHjllBD(~$ z;NOvD>{s9vOo2&QZ+=^KK40`6b(Yh<6J34u{ksgh5B_aMvWd8C zGw==U2CHedl9IT;o%l~Gh#aafwSTpz`?*~1XrWNZb#--J!q)5x==ppL6QFq}zAj&A zTVA;8+3DSDrpYjh=G|cSd#t~~FQEPE{AI{0;eO24`XQLDzGuLH=F)XPbiY=i2mE}@ zR9m@e=vfxMoX^qy`FG>kH0!9Bm)6sdQPOmOy~HlU_h5JbYjl79No>6*<*Bd4*1FZ& zw!CnCAIDX=2|C|x&^JKOWDY8zeXW4@+Nm_#?>r54-cMi^dO&w>8MG(eJI&SnJeV}= zmlRiSGZu*H8R*VUCFG68u~}AqGn@5E^HbLJeZ56Y=hfMDzfIInqMu8}jzbwtEZ(kd zdEu&Ox&r6IfM=J{^&GBwzN~86e*0)P5;XMlUnI5wi%^L3{J30cwqpMk-Ni3qpo3nN zr0%bs_zjXSKuO6++If@WwW1FH(H+tJThQHMkF5JWz#RQOrFps=_h1H0vh-7KR&>ww z-TxIHg68jmtbLR(v9)fsXY0GCZ}C~sY+u^$=Ok6wgbicTxpl#RDXhCm_oG{K*Ea{< zPwQ)L?V~|=FYnyq)6PA4%DKlcJ9qkRYTtBj|C)2VmfTh!tSvdWgl`bsSKFx$;&bjF DFdI)4 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cb72c1e9a..d5a81fa0a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,6 +5,18 @@ + +