diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9dca038 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/__pycache__ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +README.md \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..937aa06 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,70 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "CodeQL" + +on: + push: + branches: [master] + pull_request: + branches-ignore: [master] + schedule: + - cron: '0 21 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + language: ['javascript', 'python'] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 \ No newline at end of file diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..e6a4139 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,89 @@ +########################### +########################### +## Linter GitHub Actions ## +########################### +########################### + +name: Lint Code Base + +# +# Documentation: +# https://help.github.com/en/articles/workflow-syntax-for-github-actions +# + +############################# +# Start the job on all push # +############################# +on: + push: + branches: [master] + pull_request: + branches-ignore: [master] + + +############### +# Set the Job # +############### +jobs: + build: + name: Lint Code Base + # Set the agent to run on + runs-on: ubuntu-latest + + ################## + # Load all steps # + ################### + steps: + ########################## + # Checkout the code base # + ########################## + - name: Checkout Code + uses: actions/checkout@v2 + with: + # Full git history is needed to get a proper list of changed files within `super-linter` + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install Required packages + run: | + sudo apt-get install -y python3-virtualenv libvirt-dev python3-lxml zlib1g-dev libxslt1-dev + + - name: Create & Activate VENV + run: | + python3 -m venv venv + source venv/bin/activate + + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + pip3 install wheel + if [ -f dev/requirements.txt ]; then pip3 install -r dev/requirements.txt; else pip3 install -r conf/requirements.txt; fi + ################################ + # Run Linter against code base # + ################################ + - name: Lint Code Base + uses: docker://github/super-linter:latest + env: + FILTER_REGEX_EXCLUDE: .*(static|scss|venv|locale)/.* + DEFAULT_BRANCH: master + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VALIDATE_ALL_CODEBASE: false + VALIDATE_ANSIBLE: false + VALIDATE_CLOJURE: false + VALIDATE_COFFEE: false + VALIDATE_DART: false + VALIDATE_GO: false + VALIDATE_JSX: false + VALIDATE_KOTLIN: false + VALIDATE_POWERSHELL: false + VALIDATE_PERL: false + VALIDATE_PHP: false + VALIDATE_RAKU: false + VALIDATE_RUBY: false + VALIDATE_TSX: false + VALIDATE_TERRAFORM: false + diff --git a/.gitignore b/.gitignore index 0772847..f90e8ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,16 @@ .vagrant venv +venv2 +.vscode .idea .DS_* +.webvirtcloud *.pyc db.sqlite3* console/cert.pem* tags dhcpd.* webvirtcloud/settings.py +*migrations/* +.coverage +htmlcov \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..024baab --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,5 @@ +image: gitpod/workspace-full + +tasks: + - init: 'echo "TODO: Replace with init/build command"' + command: 'echo "TODO: Replace with command to start project"' diff --git a/.travis.yml b/.travis.yml index aa532e7..2eed2e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,15 @@ language: python python: - - "2.7" + - "3.6" env: - - DJANGO=1.8 + - DJANGO=2.2.16 install: - - pip install -r dev/requirements.txt --use-mirrors + - pip install -r dev/requirements.txt script: - - pep8 --exclude=IPy.py --ignore=E501 vrtManager accounts computes \ - console create instances interfaces \ - networks secrets storages - - pyflakes vrtManager accounts computes console create instances interfaces \ - networks secrets storages + - pep8 --exclude=IPy.py --ignore=E501 vrtManager accounts admin appsettings computes \ + console create datasource instances interfaces \ + logs networks nwfilters secrets storages + - pyflakes vrtManager accounts admin appsettings computes console create datasource instances interfaces \ + nwfilters networks secrets storages logs - python manage.py migrate - python manage.py test --settings=webvirtcloud.settings-dev diff --git a/Dockerfile b/Dockerfile index 3bfae3c..91f9f12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,50 +1,63 @@ -FROM phusion/baseimage:0.9.17 -MAINTAINER Jethro Yu +FROM phusion/baseimage:18.04-1.0.0 + +EXPOSE 80 +EXPOSE 6080 + +# Use baseimage-docker's init system. +CMD ["/sbin/my_init"] + RUN echo 'APT::Get::Clean=always;' >> /etc/apt/apt.conf.d/99AutomaticClean -RUN apt-get update -qqy -RUN DEBIAN_FRONTEND=noninteractive apt-get -qyy install \ - -o APT::Install-Suggests=false \ - git python-virtualenv python-dev libxml2-dev libvirt-dev zlib1g-dev nginx libsasl2-modules +RUN apt-get update -qqy \ + && DEBIAN_FRONTEND=noninteractive apt-get -qyy install \ + --no-install-recommends \ + git \ + python3-venv \ + python3-dev \ + python3-lxml \ + libvirt-dev \ + zlib1g-dev \ + nginx \ + pkg-config \ + gcc \ + libsasl2-modules \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -ADD . /srv/webvirtcloud +COPY . /srv/webvirtcloud RUN chown -R www-data:www-data /srv/webvirtcloud # Setup webvirtcloud -RUN cd /srv/webvirtcloud && \ - virtualenv venv && \ +WORKDIR /srv/webvirtcloud +RUN python3 -m venv venv && \ . venv/bin/activate && \ - pip install -U pip && \ - pip install -r conf/requirements.txt && \ + pip3 install -U pip && \ + pip3 install wheel && \ + pip3 install -r conf/requirements.txt && \ chown -R www-data:www-data /srv/webvirtcloud -RUN cd /srv/webvirtcloud && . venv/bin/activate && \ - python manage.py migrate && \ +RUN . venv/bin/activate && \ + python3 manage.py migrate && \ chown -R www-data:www-data /srv/webvirtcloud # Setup Nginx -RUN echo "\ndaemon off;" >> /etc/nginx/nginx.conf && \ +RUN printf "\n%s" "daemon off;" >> /etc/nginx/nginx.conf && \ rm /etc/nginx/sites-enabled/default && \ chown -R www-data:www-data /var/lib/nginx -ADD conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ +COPY conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ # Register services to runit RUN mkdir /etc/service/nginx && \ mkdir /etc/service/nginx-log-forwarder && \ mkdir /etc/service/webvirtcloud && \ mkdir /etc/service/novnc -ADD conf/runit/nginx /etc/service/nginx/run -ADD conf/runit/nginx-log-forwarder /etc/service/nginx-log-forwarder/run -ADD conf/runit/novncd.sh /etc/service/novnc/run -ADD conf/runit/webvirtcloud.sh /etc/service/webvirtcloud/run - -EXPOSE 80 -EXPOSE 6080 +COPY conf/runit/nginx /etc/service/nginx/run +COPY conf/runit/nginx-log-forwarder /etc/service/nginx-log-forwarder/run +COPY conf/runit/novncd.sh /etc/service/novnc/run +COPY conf/runit/webvirtcloud.sh /etc/service/webvirtcloud/run # Define mountable directories. #VOLUME [] -# Use baseimage-docker's init system. -CMD ["/sbin/my_init"] +WORKDIR /srv/webvirtcloud diff --git a/README.md b/README.md index 8ec15f5..637fef2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,21 @@ -## WebVirtCloud Beta +[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/retspen/webvirtcloud) +# WebVirtCloud +###### Python3 & Django 2.2 ## Features - +* QEMU/KVM Hypervisor Management +* QEMU/KVM Instance Management - Create, Delete, Update +* Hypervisor & Instance web based stats +* Manage Multiple QEMU/KVM Hypervisor +* Manage Hypervisor Datastore pools +* Manage Hypervisor Networks +* Instance Console Access with Browsers +* Libvirt API based web management UI +* User Based Authorization and Authentication * User can add SSH public key to root in Instance (Tested only Ubuntu) * User can change root password in Instance (Tested only Ubuntu) +* Supports cloud-init datasource interface ### Warning!!! @@ -15,23 +26,38 @@ wget -O - https://clck.ru/9VMRH | sudo tee -a /usr/local/bin/gstfsd sudo service supervisor restart ``` -### Description +## Description WebVirtCloud is a virtualization web interface for admins and users. It can delegate Virtual Machine's to users. A noVNC viewer presents a full graphical console to the guest domain. KVM is currently the only hypervisor supported. +## Quick Install with Installer (Beta) + +Install an OS and run specified commands. Installer supported OSes: Ubuntu 18.04, Debian 10, Centos/OEL/RHEL 8. +It can be installed on a virtual machine, physical host or on a KVM host. + +```bash +wget https://raw.githubusercontent.com/retspen/webvirtcloud/master/install.sh +chmod 744 install.sh +# run with sudo or root user +./install.sh +``` + +## Manual Installation + ### Generate secret key + You should generate SECRET_KEY after cloning repo. Then put it into webvirtcloud/settings.py. -```python +```python3 import random, string haystack = string.ascii_letters + string.digits + string.punctuation print(''.join([random.SystemRandom().choice(haystack) for _ in range(50)])) ``` -### Install WebVirtCloud panel (Ubuntu) +### Install WebVirtCloud panel (Ubuntu 18.04+ LTS) ```bash -sudo apt-get -y install git python-virtualenv python-dev libxml2-dev libvirt-dev zlib1g-dev nginx supervisor libsasl2-modules gcc pkg-config +sudo apt-get -y install git virtualenv python3-virtualenv python3-dev python3-lxml libvirt-dev zlib1g-dev libxslt1-dev nginx supervisor libsasl2-modules gcc pkg-config python3-guestfs git clone https://github.com/retspen/webvirtcloud cd webvirtcloud cp webvirtcloud/settings.py.template webvirtcloud/settings.py @@ -42,10 +68,10 @@ cd .. sudo mv webvirtcloud /srv sudo chown -R www-data:www-data /srv/webvirtcloud cd /srv/webvirtcloud -virtualenv venv +virtualenv -p python3 venv source venv/bin/activate pip install -r conf/requirements.txt -python manage.py migrate +python3 manage.py migrate sudo chown -R www-data:www-data /srv/webvirtcloud sudo rm /etc/nginx/sites-enabled/default ``` @@ -63,10 +89,15 @@ Setup libvirt and KVM on server wget -O - https://clck.ru/9V9fH | sudo sh ``` -### Install WebVirtCloud panel (CentOS) +Done!! + +Go to http://serverip and you should see the login screen. + +### Install WebVirtCloud panel (CentOS8/OEL8) ```bash -sudo yum -y install python-virtualenv python-devel libvirt-devel glibc gcc nginx supervisor libxml2 libxml2-devel git +sudo yum -y install epel-release +sudo yum -y install python3-virtualenv python3-devel libvirt-devel glibc gcc nginx supervisor python3-lxml git python3-libguestfs iproute-tc cyrus-sasl-md5 python3-libguestfs ``` #### Creating directories and cloning repo @@ -76,19 +107,23 @@ sudo mkdir /srv && cd /srv sudo git clone https://github.com/retspen/webvirtcloud && cd webvirtcloud cp webvirtcloud/settings.py.template webvirtcloud/settings.py # now put secret key to webvirtcloud/settings.py +# create secret key manually or use that command +sudo sed -r "s/SECRET_KEY = ''/SECRET_KEY = '"`python3 /srv/webvirtcloud/conf/runit/secret_generator.py`"'/" -i /srv/webvirtcloud/webvirtcloud/settings.py ``` #### Start installation webvirtcloud -``` -sudo virtualenv venv -sudo source venv/bin/activate -sudo venv/bin/pip install -r conf/requirements.txt -sudo cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ -sudo venv/bin/python manage.py migrate + +```bash +virtualenv-3 venv +source venv/bin/activate +pip3 install -r conf/requirements.txt +cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ +python3 manage.py migrate ``` #### Configure the supervisor for CentOS -Add the following after the [include] line (after **files = ... ** actually): + +Add the following after the [include] line (after **files = ...** actually): ```bash sudo vim /etc/supervisord.conf @@ -101,7 +136,7 @@ autorestart=true redirect_stderr=true [program:novncd] -command=/srv/webvirtcloud/venv/bin/python /srv/webvirtcloud/console/novncd +command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/novncd directory=/srv/webvirtcloud user=nginx autostart=true @@ -110,9 +145,10 @@ redirect_stderr=true ``` #### Edit the nginx.conf file + You will need to edit the main nginx.conf file as the one that comes from the rpm's will not work. Comment the following lines: -``` +```bash # server { # listen 80 default_server; # listen [::]:80 default_server; @@ -137,7 +173,8 @@ You will need to edit the main nginx.conf file as the one that comes from the rp ``` Also make sure file in **/etc/nginx/conf.d/webvirtcloud.conf** has the proper paths: -``` + +```bash upstream gunicorn_server { #server unix:/srv/webvirtcloud/venv/wvcloud.socket fail_timeout=0; server 127.0.0.1:8000 fail_timeout=0; @@ -159,9 +196,9 @@ server { proxy_set_header X-Forwarded-for $proxy_add_x_forwarded_for; proxy_set_header Host $host:$server_port; proxy_set_header X-Forwarded-Proto $remote_addr; - proxy_connect_timeout 600; - proxy_read_timeout 600; - proxy_send_timeout 600; + proxy_connect_timeout 1800; + proxy_read_timeout 1800; + proxy_send_timeout 1800; client_max_body_size 1024M; } } @@ -177,34 +214,48 @@ Change permission for selinux: ```bash sudo semanage fcontext -a -t httpd_sys_content_t "/srv/webvirtcloud(/.*)" +sudo setsebool -P httpd_can_network_connect on -P ``` -Add required user to the kvm group: +Add required user to the kvm group(if you not install with root): + ```bash -sudo usermod -G kvm -a webvirtmgr +sudo usermod -G kvm -a +``` + +Allow http ports on firewall: + +```bash +sudo firewall-cmd --add-service=http +sudo firewall-cmd --add-service=http --permanent +sudo firewall-cmd --add-port=6080/tcp +sudo firewall-cmd --add-port=6080/tcp --permanent ``` Let's restart nginx and the supervisord services: + ```bash sudo systemctl restart nginx && systemctl restart supervisord ``` And finally, check everything is running: + ```bash sudo supervisorctl status - -novncd RUNNING pid 24186, uptime 2:59:14 -webvirtcloud RUNNING pid 24185, uptime 2:59:14 - +gstfsd RUNNING pid 24662, uptime 6:01:40 +novncd RUNNING pid 24661, uptime 6:01:40 +webvirtcloud RUNNING pid 24660, uptime 6:01:40 ``` #### Apache mod_wsgi configuration -``` + +```bash WSGIDaemonProcess webvirtcloud threads=2 maximum-requests=1000 display-name=webvirtcloud -WSGIScriptAlias / /srv/webvirtcloud/webvirtcloud/wsgi.py +WSGIScriptAlias / /srv/webvirtcloud/webvirtcloud/wsgi_custom.py ``` #### Install final required packages for libvirtd and others on Host Server + ```bash wget -O - https://clck.ru/9V9fH | sudo sh ``` @@ -213,19 +264,133 @@ Done!! Go to http://serverip and you should see the login screen. +### Alternative running novncd via runit(Debian) + +Alternative to running nonvcd via supervisor is runit. + +On Debian systems install runit and configure novncd service: + +```bash +apt install runit runit-systemd +mkdir /etc/service/novncd/ +ln -s /srv/webvirtcloud/conf/runit/novncd.sh /etc/service/novncd/run +systemctl start runit.service +``` + ### Default credentials -
+
+```html
 login: admin
 password: admin
-
+``` + +### Configuring Compute SSH connection + +This is a short example of configuring cloud and compute side of the ssh connection. + +On the webvirtcloud machine you need to generate ssh keys and optionally disable StrictHostKeyChecking. -### How To Update ```bash +chown www-data -R ~www-data +sudo -u www-data ssh-keygen +cat > ~www-data/.ssh/config << EOF +Host * +StrictHostKeyChecking no +EOF +chown www-data -R ~www-data/.ssh/config +``` + +You need to put cloud public key into authorized keys on the compute node. Simpliest way of doing this is to use ssh tool from the webvirtcloud server. + +```bash +sudo -u www-data ssh-copy-id root@compute1 +``` + +### Host SMBIOS information is not available + +If you see warning + +```bash +Unsupported configuration: Host SMBIOS information is not available +``` + +Then you need to install `dmidecode` package on your host using your package manager and restart libvirt daemon. + +Debian/Ubuntu like: + +```bash +sudo apt-get install dmidecode +sudo service libvirt-bin restart +``` + +Arch Linux + +```bash +sudo pacman -S dmidecode +systemctl restart libvirtd +``` + +### Cloud-init + +Currently supports only root ssh authorized keys and hostname. Example configuration of the cloud-init client follows. + +```bash +datasource: + OpenStack: + metadata_urls: [ "http://webvirtcloud.domain.com/datasource" ] +``` + +### Reverse-Proxy + +Edit WS_PUBLIC_PORT at settings.py file to expose redirect to 80 or 443. Default: 6080 + +```bash +WS_PUBLIC_PORT = 80 +``` + +## How To Update + +```bash +# Go to Installation Directory +cd /srv/webvirtcloud +source venv/bin/activate git pull -python manage.py migrate +pip3 install -U -r conf/requirements.txt +python3 manage.py migrate sudo service supervisor restart ``` -### License +### Running tests + +Server on which tests will be performed must have libvirt up and running. +It must not contain vms. +It must have `default` storage which not contain any disk images. +It must have `default` network which must be on. +Setup venv + +```bash +python -m venv venv +source venv/bin/activate +pip install -r conf/requirements.txt +``` + +Run tests + +```bash +python manage.py test +``` + +## Screenshots + +Instance Detail: + +Instance List:
+ + +Other:
+ + + +## License WebVirtCloud is licensed under the [Apache Licence, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html). diff --git a/Vagrantfile b/Vagrantfile index 539468d..d92377c 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -2,17 +2,53 @@ # vi: set ft=ruby : Vagrant.configure(2) do |config| - config.vm.box = "ubuntu/trusty64" - config.vm.hostname = "webvirtcloud" - config.vm.network "private_network", ip: "192.168.33.10" - config.vm.provision "shell", inline: <<-SHELL + # Default machine, if name not specified... + config.vm.define "dev", primary: true do |dev| + dev.vm.box = "ubuntu/bionic64" + dev.vm.hostname = "webvirtcloud" + dev.vm.network "private_network", ip: "192.168.33.10" + dev.vm.provision "shell", inline: <<-SHELL sudo sh /vagrant/dev/libvirt-bootstrap.sh sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf sudo service libvirt-bin restart sudo adduser vagrant libvirtd - sudo apt-get -y install python-virtualenv python-dev libxml2-dev libvirt-dev zlib1g-dev - virtualenv /vagrant/venv + sudo apt-get -y install python3-virtualenv virtualenv python3-pip python3-dev python3-lxml libvirt-dev zlib1g-dev python3-guestfs + virtualenv -p python3 /vagrant/venv source /vagrant/venv/bin/activate - pip install -r /vagrant/dev/requirements.txt - SHELL + pip3 install -r /vagrant/dev/requirements.txt + SHELL + end + # To start this machine run "vagrant up prod" + # To enter this machine run "vagrant ssh prod" + config.vm.define "prod", autostart: false do |prod| + prod.vm.box = "ubuntu/bionic64" + prod.vm.hostname = "webvirtcloud" + prod.vm.network "private_network", ip: "192.168.33.11" + prod.vm.network "forwarded_port", guest: 80, host: 8081 + #prod.vm.synced_folder ".", "/srv/webvirtcloud" + prod.vm.provision "shell", inline: <<-SHELL + sudo mkdir /srv/webvirtcloud + sudo cp -R /vagrant/* /srv/webvirtcloud + sudo sh /srv/webvirtcloud/dev/libvirt-bootstrap.sh + sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf + sudo service libvirt-bin restart + sudo adduser vagrant libvirtd + sudo chown -R vagrant:vagrant /srv/webvirtcloud + sudo apt-get -y install python3-virtualenv python3-dev python3-lxml python3-pip virtualenv libvirt-dev zlib1g-dev libxslt1-dev nginx supervisor libsasl2-modules gcc pkg-config python3-guestfs + virtualenv -p python3 /srv/webvirtcloud/venv + source /srv/webvirtcloud/venv/bin/activate + pip3 install -r /srv/webvirtcloud/requirements.txt + sudo cp /srv/webvirtcloud/conf/supervisor/webvirtcloud.conf /etc/supervisor/conf.d + sudo cp /srv/webvirtcloud/conf/nginx/webvirtcloud.conf /etc/nginx/conf.d + sudo cp /srv/webvirtcloud/webvirtcloud/settings.py.template /srv/webvirtcloud/webvirtcloud/settings.py + sudo sed "s/SECRET_KEY = ''/SECRET_KEY = '"`python3 /srv/webvirtcloud/conf/runit/secret_generator.py`"'/" -i /srv/webvirtcloud/webvirtcloud/settings.py + python3 /srv/webvirtcloud/manage.py migrate + sudo rm /etc/nginx/sites-enabled/default + sudo chown -R www-data:www-data /srv/webvirtcloud + sudo service nginx restart + sudo service supervisor restart + SHELL + end end + + diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..c419263 --- /dev/null +++ b/_config.yml @@ -0,0 +1 @@ +theme: jekyll-theme-cayman \ No newline at end of file diff --git a/accounts/__init__.py b/accounts/__init__.py index e69de29..8319823 100644 --- a/accounts/__init__.py +++ b/accounts/__init__.py @@ -0,0 +1 @@ +default_app_config = 'accounts.apps.AccountsConfig' diff --git a/accounts/admin.py b/accounts/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..0192800 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,51 @@ +from django.apps import AppConfig +from django.db.models.signals import post_migrate + + +def apply_change_password(sender, **kwargs): + ''' + Apply new change_password permission for all users + Depending on settings SHOW_PROFILE_EDIT_PASSWORD + ''' + from django.conf import settings + from django.contrib.auth.models import Permission, User + if hasattr(settings, 'SHOW_PROFILE_EDIT_PASSWORD'): + print('\033[1m! \033[92mSHOW_PROFILE_EDIT_PASSWORD is found inside settings.py\033[0m') + print('\033[1m* \033[92mApplying permission can_change_password for all users\033[0m') + users = User.objects.all() + permission = Permission.objects.get(codename='change_password') + if settings.SHOW_PROFILE_EDIT_PASSWORD: + print('\033[1m! \033[91mWarning!!! Setting to True for all users\033[0m') + for user in users: + user.user_permissions.add(permission) + else: + print('\033[1m* \033[91mWarning!!! Setting to False for all users\033[0m') + for user in users: + user.user_permissions.remove(permission) + print('\033[1m! Don`t forget to remove the option from settings.py\033[0m') + + +def create_admin(sender, **kwargs): + ''' + Create initial admin user + ''' + from accounts.models import UserAttributes + from django.contrib.auth.models import User + + plan = kwargs.get('plan', []) + for migration, rolled_back in plan: + if migration.app_label == 'accounts' and migration.name == '0001_initial' and not rolled_back: + if User.objects.count() == 0: + print('\033[1m* \033[92mCreating default admin user\033[0m') + admin = User.objects.create_superuser('admin', None, 'admin') + UserAttributes(user=admin, max_instances=-1, max_cpus=-1, max_memory=-1, max_disk_size=-1).save() + break + + +class AccountsConfig(AppConfig): + name = 'accounts' + verbose_name = 'Accounts' + + def ready(self): + post_migrate.connect(create_admin, sender=self) + post_migrate.connect(apply_change_password, sender=self) diff --git a/accounts/backends.py b/accounts/backends.py deleted file mode 100644 index e66b94a..0000000 --- a/accounts/backends.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.contrib.auth.backends import RemoteUserBackend -from accounts.models import UserInstance, UserAttributes -from instances.models import Instance - -class MyRemoteUserBackend(RemoteUserBackend): - - #create_unknown_user = True - - def configure_user(self, user): - #user.is_superuser = True - UserAttributes.configure_user(user) - return user - diff --git a/accounts/forms.py b/accounts/forms.py index 4127ac4..cbd3425 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -1,25 +1,75 @@ -import re -from django import forms -from django.utils.translation import ugettext_lazy as _ -from django.contrib.auth.models import User -from django.conf import settings +from appsettings.settings import app_settings +from django.contrib.auth import get_user_model +from django.forms import EmailField, Form, ModelForm, ValidationError +from django.utils.translation import gettext_lazy as _ + +from .models import UserInstance, UserSSHKey +from .utils import validate_ssh_key -class UserAddForm(forms.Form): - name = forms.CharField(label="Name", - error_messages={'required': _('No User name has been entered')}, - max_length=20) - password = forms.CharField(required=not settings.ALLOW_EMPTY_PASSWORD, error_messages={'required': _('No password has been entered')},) +class UserInstanceForm(ModelForm): + def __init__(self, *args, **kwargs): + super(UserInstanceForm, self).__init__(*args, **kwargs) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('^[a-z0-9]+$', name) - if not have_symbol: - raise forms.ValidationError(_('The flavor name must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The flavor name must not exceed 20 characters')) - try: - User.objects.get(username=name) - except User.DoesNotExist: - return name - raise forms.ValidationError(_('Flavor name is already use')) + # Make user and instance fields not editable after creation + instance = getattr(self, 'instance', None) + if instance and instance.id is not None: + self.fields['user'].disabled = True + self.fields['instance'].disabled = True + + def clean_instance(self): + instance = self.cleaned_data['instance'] + if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == 'False': + exists = UserInstance.objects.filter(instance=instance) + if exists: + raise ValidationError(_('Instance owned by another user')) + + return instance + + class Meta: + model = UserInstance + fields = '__all__' + + +class ProfileForm(ModelForm): + class Meta: + model = get_user_model() + fields = ('first_name', 'last_name', 'email') + + +class UserSSHKeyForm(ModelForm): + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + self.publickeys = UserSSHKey.objects.filter(user=self.user) + super().__init__(*args, **kwargs) + + def clean_keyname(self): + for key in self.publickeys: + if self.cleaned_data['keyname'] == key.keyname: + raise ValidationError(_("Key name already exist")) + + return self.cleaned_data['keyname'] + + def clean_keypublic(self): + for key in self.publickeys: + if self.cleaned_data['keypublic'] == key.keypublic: + raise ValidationError(_("Public key already exist")) + + if not validate_ssh_key(self.cleaned_data['keypublic']): + raise ValidationError(_('Invalid key')) + return self.cleaned_data['keypublic'] + + def save(self, commit=True): + ssh_key = super().save(commit=False) + ssh_key.user = self.user + if commit: + ssh_key.save() + return ssh_key + + class Meta: + model = UserSSHKey + fields = ('keyname', 'keypublic') + + +class EmailOTPForm(Form): + email = EmailField(label=_('Email')) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index 3989532..6c41032 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,29 +1,50 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations +# Generated by Django 2.2.10 on 2020-01-28 07:01 +import django.core.validators +import django.db.models.deletion from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): + initial = True + dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('instances', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='UserSSHKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('keyname', models.CharField(max_length=25)), + ('keypublic', models.CharField(max_length=500)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)), + ], + ), migrations.CreateModel( name='UserInstance', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('is_change', models.BooleanField(default=False)), ('is_delete', models.BooleanField(default=False)), - ('instance', models.ForeignKey(to='instances.Instance')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('is_vnc', models.BooleanField(default=False)), + ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='instances.Instance')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserAttributes', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('can_clone_instances', models.BooleanField(default=True)), + ('max_instances', models.IntegerField(default=1, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)])), + ('max_cpus', models.IntegerField(default=1, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)])), + ('max_memory', models.IntegerField(default=2048, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)])), + ('max_disk_size', models.IntegerField(default=20, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)])), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], - options={ - }, - bases=(models.Model,), ), ] diff --git a/accounts/migrations/0002_auto_20150325_0846.py b/accounts/migrations/0002_auto_20150325_0846.py deleted file mode 100644 index 8780f97..0000000 --- a/accounts/migrations/0002_auto_20150325_0846.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations - - -def add_useradmin(apps, schema_editor): - from django.utils import timezone - from django.contrib.auth.models import User - - User.objects.create_superuser('admin', None, 'admin', - last_login=timezone.now() - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0001_initial'), - ] - - operations = [ - migrations.RunPython(add_useradmin), - ] diff --git a/accounts/migrations/0002_permissionset.py b/accounts/migrations/0002_permissionset.py new file mode 100644 index 0000000..5b36210 --- /dev/null +++ b/accounts/migrations/0002_permissionset.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.12 on 2020-05-27 12:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PermissionSet', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'permissions': (('change_password', 'Can change password'), ), + 'managed': False, + 'default_permissions': (), + }, + ), + ] diff --git a/accounts/migrations/0003_auto_20200604_0930.py b/accounts/migrations/0003_auto_20200604_0930.py new file mode 100644 index 0000000..05c9232 --- /dev/null +++ b/accounts/migrations/0003_auto_20200604_0930.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.12 on 2020-06-04 09:30 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_permissionset'), + ] + + operations = [ + migrations.AlterField( + model_name='userattributes', + name='max_cpus', + field=models.IntegerField(default=2, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)]), + ), + migrations.AlterField( + model_name='userattributes', + name='max_instances', + field=models.IntegerField(default=2, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)]), + ), + ] diff --git a/accounts/migrations/0003_usersshkey.py b/accounts/migrations/0003_usersshkey.py deleted file mode 100644 index b00bc62..0000000 --- a/accounts/migrations/0003_usersshkey.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -from django.conf import settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('accounts', '0002_auto_20150325_0846'), - ] - - operations = [ - migrations.CreateModel( - name='UserSSHKey', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('keyname', models.CharField(max_length=25)), - ('keypublic', models.CharField(max_length=500)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/accounts/migrations/0004_auto_20200615_0637.py b/accounts/migrations/0004_auto_20200615_0637.py new file mode 100644 index 0000000..c24bd98 --- /dev/null +++ b/accounts/migrations/0004_auto_20200615_0637.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.13 on 2020-06-15 06:37 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_auto_20200604_0930'), + ] + + operations = [ + migrations.AlterField( + model_name='userattributes', + name='max_cpus', + field=models.IntegerField(default=2, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max CPUs'), + ), + migrations.AlterField( + model_name='userattributes', + name='max_disk_size', + field=models.IntegerField(default=20, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max disk size'), + ), + migrations.AlterField( + model_name='userattributes', + name='max_instances', + field=models.IntegerField(default=2, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max instances'), + ), + migrations.AlterField( + model_name='userattributes', + name='max_memory', + field=models.IntegerField(default=2048, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max memory'), + ), + migrations.AlterField( + model_name='usersshkey', + name='keyname', + field=models.CharField(max_length=25, verbose_name='key name'), + ), + migrations.AlterField( + model_name='usersshkey', + name='keypublic', + field=models.CharField(max_length=500, verbose_name='public key'), + ), + ] diff --git a/accounts/migrations/0004_userattributes.py b/accounts/migrations/0004_userattributes.py deleted file mode 100644 index fb32539..0000000 --- a/accounts/migrations/0004_userattributes.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -from django.conf import settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('accounts', '0003_usersshkey'), - ] - - operations = [ - migrations.CreateModel( - name='UserAttributes', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('max_instances', models.IntegerField(default=0)), - ('max_cpus', models.IntegerField(default=0)), - ('max_memory', models.IntegerField(default=0)), - ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/accounts/migrations/0004_userinstance_is_vnc.py b/accounts/migrations/0004_userinstance_is_vnc.py deleted file mode 100644 index 9c1c9b8..0000000 --- a/accounts/migrations/0004_userinstance_is_vnc.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0003_usersshkey'), - ] - - operations = [ - migrations.AddField( - model_name='userinstance', - name='is_vnc', - field=models.BooleanField(default=False), - ), - ] diff --git a/accounts/migrations/0005_auto_20200616_1039.py b/accounts/migrations/0005_auto_20200616_1039.py new file mode 100644 index 0000000..bf48ddc --- /dev/null +++ b/accounts/migrations/0005_auto_20200616_1039.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.13 on 2020-06-16 10:39 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('instances', '0003_auto_20200615_0637'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accounts', '0004_auto_20200615_0637'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='userinstance', + unique_together={('user', 'instance')}, + ), + ] diff --git a/accounts/migrations/0005_userattributes_can_clone_instances.py b/accounts/migrations/0005_userattributes_can_clone_instances.py deleted file mode 100644 index 4539657..0000000 --- a/accounts/migrations/0005_userattributes_can_clone_instances.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0004_userattributes'), - ] - - operations = [ - migrations.AddField( - model_name='userattributes', - name='can_clone_instances', - field=models.BooleanField(default=False), - ), - ] diff --git a/accounts/migrations/0006_userattributes_max_disk_size.py b/accounts/migrations/0006_userattributes_max_disk_size.py deleted file mode 100644 index 3d21f5f..0000000 --- a/accounts/migrations/0006_userattributes_max_disk_size.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0005_userattributes_can_clone_instances'), - ] - - operations = [ - migrations.AddField( - model_name='userattributes', - name='max_disk_size', - field=models.IntegerField(default=0), - ), - ] diff --git a/accounts/migrations/0007_auto_20160426_0635.py b/accounts/migrations/0007_auto_20160426_0635.py deleted file mode 100644 index 2f92aba..0000000 --- a/accounts/migrations/0007_auto_20160426_0635.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0006_userattributes_max_disk_size'), - ] - - operations = [ - migrations.AlterField( - model_name='userattributes', - name='max_cpus', - field=models.IntegerField(default=1), - ), - migrations.AlterField( - model_name='userattributes', - name='max_disk_size', - field=models.IntegerField(default=20), - ), - migrations.AlterField( - model_name='userattributes', - name='max_instances', - field=models.IntegerField(default=1), - ), - migrations.AlterField( - model_name='userattributes', - name='max_memory', - field=models.IntegerField(default=2048), - ), - ] diff --git a/accounts/migrations/0008_merge.py b/accounts/migrations/0008_merge.py deleted file mode 100644 index 8edf672..0000000 --- a/accounts/migrations/0008_merge.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0004_userinstance_is_vnc'), - ('accounts', '0007_auto_20160426_0635'), - ] - - operations = [ - ] diff --git a/accounts/migrations/0009_auto_20171026_0805.py b/accounts/migrations/0009_auto_20171026_0805.py deleted file mode 100644 index 7d035c7..0000000 --- a/accounts/migrations/0009_auto_20171026_0805.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0008_merge'), - ] - - operations = [ - migrations.AlterField( - model_name='userattributes', - name='can_clone_instances', - field=models.BooleanField(default=True), - ), - ] diff --git a/accounts/models.py b/accounts/models.py index 19e3a20..1fcc880 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,57 +1,78 @@ -from django.db import models from django.contrib.auth.models import User -from django.conf import settings +from django.core.validators import MinValueValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ from instances.models import Instance +class UserInstanceManager(models.Manager): + def get_queryset(self): + return super().get_queryset().select_related('instance', 'user') + + class UserInstance(models.Model): - user = models.ForeignKey(User) - instance = models.ForeignKey(Instance) + user = models.ForeignKey(User, on_delete=models.CASCADE) + instance = models.ForeignKey(Instance, on_delete=models.CASCADE) is_change = models.BooleanField(default=False) is_delete = models.BooleanField(default=False) is_vnc = models.BooleanField(default=False) - def __unicode__(self): - return self.instance.name + objects = UserInstanceManager() + + def __str__(self): + return _('Instance "%(inst)s" of user %(user)s') % {"inst": self.instance, "user": self.user} + + class Meta: + unique_together = ['user', 'instance'] class UserSSHKey(models.Model): - user = models.ForeignKey(User) - keyname = models.CharField(max_length=25) - keypublic = models.CharField(max_length=500) + user = models.ForeignKey(User, on_delete=models.DO_NOTHING) + keyname = models.CharField(_('key name'), max_length=25) + keypublic = models.CharField(_('public key'), max_length=500) - def __unicode__(self): + def __str__(self): return self.keyname + class UserAttributes(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) can_clone_instances = models.BooleanField(default=True) - max_instances = models.IntegerField(default=1) - max_cpus = models.IntegerField(default=1) - max_memory = models.IntegerField(default=2048) - max_disk_size = models.IntegerField(default=20) + max_instances = models.IntegerField(_('max instances'), + default=2, + help_text=_("-1 for unlimited. Any integer value"), + validators=[ + MinValueValidator(-1), + ]) + max_cpus = models.IntegerField( + _('max CPUs'), + default=2, + help_text=_("-1 for unlimited. Any integer value"), + validators=[MinValueValidator(-1)], + ) + max_memory = models.IntegerField( + _('max memory'), + default=2048, + help_text=_("-1 for unlimited. Any integer value"), + validators=[MinValueValidator(-1)], + ) + max_disk_size = models.IntegerField( + _('max disk size'), + default=20, + help_text=_("-1 for unlimited. Any integer value"), + validators=[MinValueValidator(-1)], + ) - @staticmethod - def create_missing_userattributes(user): - try: - userattributes = user.userattributes - except UserAttributes.DoesNotExist: - userattributes = UserAttributes(user=user) - userattributes.save() - - @staticmethod - def add_default_instances(user): - existing_instances = UserInstance.objects.filter(user=user) - if not existing_instances: - for instance_name in settings.NEW_USER_DEFAULT_INSTANCES: - instance = Instance.objects.get(name=instance_name) - user_instance = UserInstance(user=user, instance=instance) - user_instance.save() - - @staticmethod - def configure_user(user): - UserAttributes.create_missing_userattributes(user) - UserAttributes.add_default_instances(user) - - def __unicode__(self): + def __str__(self): return self.user.username + + +class PermissionSet(models.Model): + """ + Dummy model for holding set of permissions we need to be automatically added by Django + """ + class Meta: + default_permissions = () + permissions = (('change_password', _('Can change password')), ) + + managed = False diff --git a/accounts/templates/account.html b/accounts/templates/account.html index 0b39978..b44aa8e 100644 --- a/accounts/templates/account.html +++ b/accounts/templates/account.html @@ -1,140 +1,89 @@ {% extends "base.html" %} + {% load i18n %} -{% block title %}{% trans "User" %} - {{ user }}{% endblock %} +{% load icons %} +{% load qr_code %} + +{% block title %}{% trans "User Profile" %} - {{ user }}{% endblock %} +{% block page_heading %}{% trans "User Profile" %}: {{ user }}{% endblock page_heading %} + +{% block page_heading_extra %} +{% if otp_enabled %} + + {% icon 'qrcode' %} + +{% endif %} + + {% icon 'pencil' %} + + + {% icon 'plus' %} + +{% endblock page_heading_extra %} + {% block content %} - -
-
- {% include 'create_user_inst_block.html' %} -

{{ user }}

-
-
- + - {% include 'errors_block.html' %} - - {% if request.user.is_superuser and publickeys %} -
-
-
- - - - - - - - - {% for publickey in publickeys %} - - - - - {% endfor %} - -
{% trans "Key name" %}{% trans "Public key" %}
{{ publickey.keyname }}{{ publickey.keypublic|truncatechars:64 }}
-
-
-
- {% endif %} - -
-
- {% if not user_insts %} -
-
- - {% trans "Warning:" %} {% trans "User doesn't have any Instace" %} -
-
- {% else %} -
- - - - - - - - - - - - - {% for inst in user_insts %} - - - - - - - - - - {% endfor %} - -
#{% trans "Instance" %}{% trans "VNC" %}{% trans "Resize" %}{% trans "Delete" %}{% trans "Action" %}
{{ forloop.counter }}{{ inst.instance.name }}{{ inst.is_vnc }}{{ inst.is_change }}{{ inst.is_delete }} - - - - - - - -
{% csrf_token %} - - -
-
-
- {% endif %} -
-
-{% endblock %} +
+
+ + + + + + + + + + + + + {% for inst in user_insts %} + + + + + + + + + + {% endfor %} + +
#{% trans "Instance" %}{% trans "VNC" %}{% trans "Resize" %}{% trans "Delete" %}{% trans "Action" %}
{{ forloop.counter }}{{ inst.instance.name }}{{ inst.is_vnc }}{{ inst.is_change }}{{ inst.is_delete }} + + {% icon 'pencil' %} + + + + {% icon 'trash' %} + +
+
+
+ + + + + + + + + {% for publickey in publickeys %} + + + + + {% endfor %} + +
{% trans "Key name" %}{% trans "Public key" %}
{{ publickey.keyname }}{{ publickey.keypublic|truncatechars:64 }}
+
+
+{% endblock content %} diff --git a/accounts/templates/accounts.html b/accounts/templates/accounts.html deleted file mode 100644 index 4fdfc8a..0000000 --- a/accounts/templates/accounts.html +++ /dev/null @@ -1,144 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block title %}{% trans "Users" %}{% endblock %} -{% block content %} - -
-
- {% include 'create_user_block.html' %} -

{% trans "Users" %}

-
-
- - - {% include 'errors_block.html' %} - -
- {% if not users %} -
-
- - {% trans "Warning:" %} {% trans "You don't have any User" %} -
-
- {% else %} - {% for user in users %} -
-
- -
-
-

{% trans "Status:" %}

-
-
- {% if user.is_active %} -

{% trans "Active" %}

- {% else %} -

{% trans "Blocked" %}

- {% endif %} -
-
-
-
- - - - {% endfor %} - {% endif %} -
-{% endblock %} diff --git a/accounts/templates/accounts/change_password_form.html b/accounts/templates/accounts/change_password_form.html new file mode 100644 index 0000000..398a048 --- /dev/null +++ b/accounts/templates/accounts/change_password_form.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% load bootstrap4 %} +{% load i18n %} +{% load icons %} + +{% block title %}{%trans "Change Password" %}{% endblock title %} + +{% block content %} +
+
+
+
+

{%trans "Change Password" %}: {{ user }}

+
+
+
+ {% csrf_token %} + {% bootstrap_form form layout='horizontal' %} +
+
+ +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/accounts/templates/accounts/email/otp.html b/accounts/templates/accounts/email/otp.html new file mode 100644 index 0000000..2833194 --- /dev/null +++ b/accounts/templates/accounts/email/otp.html @@ -0,0 +1,7 @@ +{% load i18n %} +{% load qr_code %} +{% blocktrans %} +Scan this QR code to get OTP for account '{{ user }}' +{% endblocktrans %} +
+{% qr_from_text totp_url %} \ No newline at end of file diff --git a/accounts/templates/accounts/email_otp_form.html b/accounts/templates/accounts/email_otp_form.html new file mode 100644 index 0000000..03fa06d --- /dev/null +++ b/accounts/templates/accounts/email_otp_form.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load bootstrap4 %} +{% load icons %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} + +{% block page_heading %}{{ title }}{% endblock page_heading %} + +{% block content %} +
+ {% blocktrans %} + Enter email address OTP QR code will be sent to. + {% endblocktrans %} +
+
+
+
+ {% csrf_token %} + {% bootstrap_form form layout='horizontal' %} +
+
+ +
+{% endblock content %} \ No newline at end of file diff --git a/accounts/templates/accounts/otp_login.html b/accounts/templates/accounts/otp_login.html new file mode 100644 index 0000000..d75678c --- /dev/null +++ b/accounts/templates/accounts/otp_login.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} +{% load bootstrap4 %} + +{% block title %}WebVirtCloud{% endblock title %} + +{% block page_heading %}WebVirtCloud{% endblock page_heading %} + +{% block content %} +
+
+
+
+ {% if form.errors %} + {% bootstrap_form_errors form %} + {% endif %} + +
+
+
+
+{% endblock content %} \ No newline at end of file diff --git a/accounts/templates/base_auth.html b/accounts/templates/base_auth.html deleted file mode 100644 index a7680bb..0000000 --- a/accounts/templates/base_auth.html +++ /dev/null @@ -1,41 +0,0 @@ -{% load static %} - - - - - - - - - - - - {% block title %}{% endblock %} - - - - - - - - - - - - - - -
- {% block content %}{% endblock %} -
- - - - - - - - \ No newline at end of file diff --git a/accounts/templates/create_user_block.html b/accounts/templates/create_user_block.html deleted file mode 100644 index f4b4679..0000000 --- a/accounts/templates/create_user_block.html +++ /dev/null @@ -1,38 +0,0 @@ -{% load i18n %} -{% if request.user.is_superuser %} - - - - - - -{% endif %} diff --git a/accounts/templates/create_user_inst_block.html b/accounts/templates/create_user_inst_block.html deleted file mode 100644 index df5937e..0000000 --- a/accounts/templates/create_user_inst_block.html +++ /dev/null @@ -1,36 +0,0 @@ -{% load i18n %} -{% if request.user.is_superuser %} - - - - - - -{% endif %} \ No newline at end of file diff --git a/accounts/templates/login.html b/accounts/templates/login.html index 362fdf7..adadaba 100644 --- a/accounts/templates/login.html +++ b/accounts/templates/login.html @@ -1,25 +1,30 @@ -{% extends "base_auth.html" %} +{% extends "base.html" %} {% load i18n %} -{% block title %}{% trans "WebVirtCloud - Sign In" %}{% endblock %} +{% load static %} + +{% block title %}{% trans "WebVirtCloud" %} - {% trans "Sign In" %}{% endblock %} + +{% block style %} + +{% endblock style %} + {% block content %} -
- {% endblock %} \ No newline at end of file diff --git a/accounts/templates/logout.html b/accounts/templates/logout.html index 83161d3..eb3a325 100644 --- a/accounts/templates/logout.html +++ b/accounts/templates/logout.html @@ -1,14 +1,16 @@ {% extends "base_auth.html" %} {% load i18n %} -{% block title %}{% trans "WebVirtCloud - Sign Out" %}{% endblock %} +{% block title %} + {% trans "WebVirtCloud" %} - {% trans "Sign Out"%} +{% endblock %} {% block content %} -
+
-
+
-

{% trans "Successful log out" %}

+

{% trans "Successful log out" %}

diff --git a/accounts/templates/profile.html b/accounts/templates/profile.html index d2213b4..37ce8da 100644 --- a/accounts/templates/profile.html +++ b/accounts/templates/profile.html @@ -1,117 +1,80 @@ {% extends "base.html" %} {% load i18n %} +{% load bootstrap4 %} +{% load icons %} {% load tags_fingerprint %} -{% block title %}{% trans "Profile" %}{% endblock %} + +{% block title %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock %} + +{% block page_heading %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock page_heading %} + {% block content %} - -
-
-

{% trans "Profile" %}

-
-
- - - {% include 'errors_block.html' %} - -
-
- -
{% csrf_token %} -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
- -
-
-
- {% if show_profile_edit_password %} - -
{% csrf_token %} -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
- -
-
-
+ +
+
+
+
+
+ {% csrf_token %} + {% bootstrap_form profile_form layout='horizontal' %} + {% if perms.accounts.change_password %} + + {% icon 'lock' %} {% trans "Change Password" %} + {% endif %} - - {% if publickeys %} -
-
- - - {% for key in publickeys %} - - - - - {% endfor %} - -
{{ key.keyname }} ({% ssh_to_fingerprint key.keypublic %}) - {% csrf_token %} - - - -
-
-
- {% endif %} -
{% csrf_token %} -
- -
- -
-
-
- -
- -
-
-
-
- -
-
-
-
+
+ +
+
-{% endblock %} +
+
+
+ {% if publickeys %} +
+
+ + + {% for key in publickeys %} + + + + + {% endfor %} + +
{{ key.keyname }} ({% ssh_to_fingerprint key.keypublic %}) + + {% icon 'trash' %} + +
+
+
+ {% endif %} +
+
+ {%trans "Add SSH Key" %} +
+
+
+ {% csrf_token %} + {% bootstrap_form ssh_key_form layout='horizontal' %} +
+ +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/accounts/templatetags/tags_fingerprint.py b/accounts/templatetags/tags_fingerprint.py index 31f3952..83cfe14 100644 --- a/accounts/templatetags/tags_fingerprint.py +++ b/accounts/templatetags/tags_fingerprint.py @@ -1,7 +1,8 @@ -from django import template import base64 import hashlib +from django import template + register = template.Library() diff --git a/accounts/tests.py b/accounts/tests.py index 7ce503c..2419990 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,3 +1,267 @@ -from django.test import TestCase +from appsettings.settings import app_settings +from computes.models import Compute +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.shortcuts import reverse +from django.test import Client, TestCase +from instances.models import Instance +from instances.utils import refr +from libvirt import VIR_DOMAIN_UNDEFINE_NVRAM +from vrtManager.create import wvmCreate -# Create your tests here. +from accounts.forms import UserInstanceForm, UserSSHKeyForm +from accounts.models import UserInstance, UserSSHKey +from accounts.utils import validate_ssh_key + + +class AccountsTestCase(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Add users for testing purposes + User = get_user_model() + cls.admin_user = User.objects.get(pk=1) + cls.test_user = User.objects.create_user(username='test', password='test') + + # Add localhost compute + cls.compute = Compute( + name='test-compute', + hostname='localhost', + login='', + password='', + details='local', + type=4, + ) + cls.compute.save() + + cls.connection = wvmCreate( + cls.compute.hostname, + cls.compute.login, + cls.compute.password, + cls.compute.type, + ) + + # Add disks for testing + cls.connection.create_volume( + 'default', + 'test-volume', + 1, + 'qcow2', + False, + 0, + 0, + ) + + # XML for testing vm + with open('conf/test-vm.xml', 'r') as f: + cls.xml = f.read() + + # Create testing vm from XML + cls.connection._defineXML(cls.xml) + refr(cls.compute) + cls.instance = Instance.objects.get(pk=1) + + @classmethod + def tearDownClass(cls): + # Destroy testing vm + cls.instance.proxy.delete_all_disks() + cls.instance.proxy.delete(VIR_DOMAIN_UNDEFINE_NVRAM) + super().tearDownClass() + + def setUp(self): + self.client.login(username='admin', password='admin') + permission = Permission.objects.get(codename='change_password') + self.test_user.user_permissions.add(permission) + self.rsa_key = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw== test@test' + self.ecdsa_key = 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJc5xpT3R0iFJYNZbmWgAiDlHquX/BcV1kVTsnBfiMsZgU3lGaqz2eb7IBcir/dxGnsVENTTmPQ6sNcxLxT9kkQ= realgecko@archlinux' + + def test_profile(self): + response = self.client.get(reverse('accounts:profile')) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse('accounts:account', args=[self.test_user.id])) + self.assertEqual(response.status_code, 200) + + def test_account_with_otp(self): + settings.OTP_ENABLED = True + response = self.client.get(reverse('accounts:account', args=[self.test_user.id])) + self.assertEqual(response.status_code, 200) + + def test_login_logout(self): + client = Client() + + response = client.post(reverse("accounts:login"), {"username": "test", "password": "test"}) + self.assertRedirects(response, reverse('accounts:profile')) + + response = client.get(reverse('accounts:logout')) + self.assertRedirects(response, reverse('accounts:login')) + + def test_change_password(self): + self.client.force_login(self.test_user) + + response = self.client.get(reverse('accounts:change_password')) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('accounts:change_password'), + { + 'old_password': 'wrongpass', + 'new_password1': 'newpw', + 'new_password2': 'newpw', + }, + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('accounts:change_password'), + { + 'old_password': 'test', + 'new_password1': 'newpw', + 'new_password2': 'newpw', + }, + ) + self.assertRedirects(response, reverse('accounts:profile')) + + self.client.logout() + + logged_in = self.client.login(username='test', password='newpw') + self.assertTrue(logged_in) + + def test_user_instance_create_update_delete(self): + # create + response = self.client.get(reverse('accounts:user_instance_create', args=[self.test_user.id])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('accounts:user_instance_create', args=[self.test_user.id]), + { + 'user': self.test_user.id, + 'instance': self.instance.id, + 'is_change': False, + 'is_delete': False, + 'is_vnc': False, + }, + ) + self.assertRedirects(response, reverse('accounts:account', args=[self.test_user.id])) + + user_instance: UserInstance = UserInstance.objects.get(pk=1) + self.assertEqual(user_instance.user, self.test_user) + self.assertEqual(user_instance.instance, self.instance) + self.assertEqual(user_instance.is_change, False) + self.assertEqual(user_instance.is_delete, False) + self.assertEqual(user_instance.is_vnc, False) + + # update + response = self.client.get(reverse('accounts:user_instance_update', args=[user_instance.id])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('accounts:user_instance_update', args=[user_instance.id]), + { + 'user': self.test_user.id, + 'instance': self.instance.id, + 'is_change': True, + 'is_delete': True, + 'is_vnc': True, + }, + ) + self.assertRedirects(response, reverse('accounts:account', args=[self.test_user.id])) + + user_instance: UserInstance = UserInstance.objects.get(pk=1) + self.assertEqual(user_instance.user, self.test_user) + self.assertEqual(user_instance.instance, self.instance) + self.assertEqual(user_instance.is_change, True) + self.assertEqual(user_instance.is_delete, True) + self.assertEqual(user_instance.is_vnc, True) + + # delete + response = self.client.get(reverse('accounts:user_instance_delete', args=[user_instance.id])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('accounts:user_instance_delete', args=[user_instance.id])) + self.assertRedirects(response, reverse('accounts:account', args=[self.test_user.id])) + + # test 'next' redirect during deletion + user_instance = UserInstance.objects.create(user=self.test_user, instance=self.instance) + response = self.client.post( + reverse('accounts:user_instance_delete', args=[user_instance.id]) + '?next=' + reverse('index')) + self.assertRedirects(response, reverse('index')) + + def test_update_user_profile(self): + self.client.force_login(self.test_user) + + user = get_user_model().objects.get(username='test') + self.assertEqual(user.first_name, '') + self.assertEqual(user.last_name, '') + self.assertEqual(user.email, '') + + response = self.client.post(reverse('accounts:profile'), { + 'first_name': 'first name', + 'last_name': 'last name', + 'email': 'email@mail.mail', + }) + self.assertRedirects(response, reverse('accounts:profile')) + + user = get_user_model().objects.get(username='test') + self.assertEqual(user.first_name, 'first name') + self.assertEqual(user.last_name, 'last name') + self.assertEqual(user.email, 'email@mail.mail') + + def test_create_delete_ssh_key(self): + response = self.client.get(reverse('accounts:ssh_key_create')) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('accounts:ssh_key_create'), { + 'keyname': 'keyname', + 'keypublic': self.rsa_key, + }) + self.assertRedirects(response, reverse('accounts:profile')) + + key = UserSSHKey.objects.get(pk=1) + self.assertEqual(key.keyname, 'keyname') + self.assertEqual(key.keypublic, self.rsa_key) + + response = self.client.get(reverse('accounts:ssh_key_delete', args=[1])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('accounts:ssh_key_delete', args=[1])) + self.assertRedirects(response, reverse('accounts:profile')) + + def test_validate_ssh_key(self): + self.assertFalse(validate_ssh_key('')) + self.assertFalse(validate_ssh_key('ssh-rsa ABBA test@test')) + self.assertFalse(validate_ssh_key('ssh-rsa AAAABwdzZGY= test@test')) + self.assertFalse(validate_ssh_key('ssh-rsa AAA test@test')) + # validate ecdsa key + self.assertTrue(validate_ssh_key(self.ecdsa_key)) + + def test_forms(self): + # raise available validation errors for maximum coverage + form = UserSSHKeyForm({'keyname': 'keyname', 'keypublic': self.rsa_key}, user=self.test_user) + form.save() + + form = UserSSHKeyForm({'keyname': 'keyname', 'keypublic': self.rsa_key}, user=self.test_user) + self.assertFalse(form.is_valid()) + + form = UserSSHKeyForm({'keyname': 'keyname', 'keypublic': 'invalid key'}, user=self.test_user) + self.assertFalse(form.is_valid()) + + app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER = 'False' + form = UserInstanceForm({ + 'user': self.admin_user.id, + 'instance': self.instance.id, + 'is_change': False, + 'is_delete': False, + 'is_vnc': False, + }) + form.save() + form = UserInstanceForm({ + 'user': self.test_user.id, + 'instance': self.instance.id, + 'is_change': False, + 'is_delete': False, + 'is_vnc': False, + }) + self.assertFalse(form.is_valid()) diff --git a/accounts/urls.py b/accounts/urls.py index f8c4a41..d98e75a 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -1,12 +1,33 @@ -from django.conf.urls import url +from django.conf import settings +from django.contrib.auth.views import LoginView, LogoutView +from django.urls import path +from django_otp.forms import OTPAuthenticationForm + from . import views +app_name = 'accounts' + urlpatterns = [ - url(r'^login/$', 'django.contrib.auth.views.login', - {'template_name': 'login.html'}, name='login'), - url(r'^logout/$', 'django.contrib.auth.views.logout', - {'template_name': 'logout.html'}, name='logout'), - url(r'^profile/$', views.profile, name='profile'), - url(r'^$', views.accounts, name='accounts'), - url(r'^profile/(?P[0-9]+)/$', views.account, name='account'), + path('logout/', LogoutView.as_view(template_name='logout.html'), name='logout'), + path('profile/', views.profile, name='profile'), + path('profile//', views.account, name='account'), + path('change_password/', views.change_password, name='change_password'), + path('user_instance/create//', views.user_instance_create, name='user_instance_create'), + path('user_instance//update/', views.user_instance_update, name='user_instance_update'), + path('user_instance//delete/', views.user_instance_delete, name='user_instance_delete'), + path('ssh_key/create/', views.ssh_key_create, name='ssh_key_create'), + path('ssh_key//delete/', views.ssh_key_delete, name='ssh_key_delete'), ] + +if settings.OTP_ENABLED: + urlpatterns += [ + path( + 'login/', + LoginView.as_view(template_name='accounts/otp_login.html', authentication_form=OTPAuthenticationForm), + name='login', + ), + path('email_otp/', views.email_otp, name='email_otp'), + path('admin_email_otp//', views.admin_email_otp, name='admin_email_otp'), + ] +else: + urlpatterns += path('login/', LoginView.as_view(template_name='login.html'), name='login'), diff --git a/accounts/utils.py b/accounts/utils.py new file mode 100644 index 0000000..da30c0a --- /dev/null +++ b/accounts/utils.py @@ -0,0 +1,62 @@ +import base64 +import binascii +import struct + +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils.translation import gettext as _ +from django_otp import devices_for_user +from django_otp.plugins.otp_totp.models import TOTPDevice + + +def get_user_totp_device(user): + devices = devices_for_user(user) + for device in devices: + if isinstance(device, TOTPDevice): + return device + + device = user.totpdevice_set.create() + return device + + +def validate_ssh_key(key): + array = key.encode().split() + # Each rsa-ssh key has 3 different strings in it, first one being + # typeofkey second one being keystring third one being username . + if len(array) != 3: + return False + typeofkey = array[0] + string = array[1] + username = array[2] + # must have only valid rsa-ssh key characters ie binascii characters + try: + data = base64.decodestring(string) + except binascii.Error: + return False + # unpack the contents of data, from data[:4] , property of ssh key . + try: + str_len = struct.unpack(">I", data[:4])[0] + except struct.error: + return False + # data[4:str_len] must have string which matches with the typeofkey, another ssh key property. + if data[4 : 4 + str_len] == typeofkey: + return True + else: + return False + + +def send_email_with_otp(user, device): + send_mail( + _("OTP QR Code"), + _("Please view HTML version of this message."), + None, + [user.email], + html_message=render_to_string( + "accounts/email/otp.html", + { + "totp_url": device.config_url, + "user": user, + }, + ), + fail_silently=False, + ) diff --git a/accounts/views.py b/accounts/views.py index 5fdfb4d..8ca3ba7 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,185 +1,197 @@ -from django.shortcuts import render -from django.http import HttpResponseRedirect -from django.core.urlresolvers import reverse -from django.utils.translation import ugettext_lazy as _ -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from accounts.models import * -from instances.models import Instance -from accounts.forms import UserAddForm +from admin.decorators import superuser_only from django.conf import settings +from django.contrib import messages +from django.contrib.auth import get_user_model, update_session_auth_hash +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.forms import PasswordChangeForm +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from instances.models import Instance + +from accounts.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm +from accounts.models import * + +from . import forms +from .utils import get_user_totp_device, send_email_with_otp -@login_required def profile(request): - """ - :param request: - :return: - """ - - error_messages = [] - user = User.objects.get(id=request.user.id) publickeys = UserSSHKey.objects.filter(user_id=request.user.id) - show_profile_edit_password = settings.SHOW_PROFILE_EDIT_PASSWORD + profile_form = ProfileForm(request.POST or None, instance=request.user) + ssh_key_form = UserSSHKeyForm() - if request.method == 'POST': - if 'username' in request.POST: - username = request.POST.get('username', '') - email = request.POST.get('email', '') - user.first_name = username - user.email = email - user.save() - return HttpResponseRedirect(request.get_full_path()) - if 'oldpasswd' in request.POST: - oldpasswd = request.POST.get('oldpasswd', '') - password1 = request.POST.get('passwd1', '') - password2 = request.POST.get('passwd2', '') - if not password1 or not password2: - error_messages.append("Passwords didn't enter") - if password1 and password2 and password1 != password2: - error_messages.append("Passwords don't match") - if not user.check_password(oldpasswd): - error_messages.append("Old password is wrong!") - if not error_messages: - user.set_password(password1) - user.save() - return HttpResponseRedirect(request.get_full_path()) - if 'keyname' in request.POST: - keyname = request.POST.get('keyname', '') - keypublic = request.POST.get('keypublic', '') - for key in publickeys: - if keyname == key.keyname: - msg = _("Key name already exist") - error_messages.append(msg) - if keypublic == key.keypublic: - msg = _("Public key already exist") - error_messages.append(msg) - if not error_messages: - addkeypublic = UserSSHKey(user_id=request.user.id, keyname=keyname, keypublic=keypublic) - addkeypublic.save() - return HttpResponseRedirect(request.get_full_path()) - if 'keydelete' in request.POST: - keyid = request.POST.get('keyid', '') - delkeypublic = UserSSHKey.objects.get(id=keyid) - delkeypublic.delete() - return HttpResponseRedirect(request.get_full_path()) - return render(request, 'profile.html', locals()) + if profile_form.is_valid(): + profile_form.save() + messages.success(request, _("Profile updated")) + return redirect("accounts:profile") -@login_required -def accounts(request): - """ - :param request: - :return: - """ - if not request.user.is_superuser: - return HttpResponseRedirect(reverse('index')) - - error_messages = [] - users = User.objects.all().order_by('username') - allow_empty_password = settings.ALLOW_EMPTY_PASSWORD - - if request.method == 'POST': - if 'create' in request.POST: - form = UserAddForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if not error_messages: - new_user = User.objects.create_user(data['name'], None, data['password']) - new_user.save() - UserAttributes.configure_user(new_user) - return HttpResponseRedirect(request.get_full_path()) - if 'edit' in request.POST: - user_id = request.POST.get('user_id', '') - user_pass = request.POST.get('user_pass', '') - user_edit = User.objects.get(id=user_id) - user_edit.set_password(user_pass) - user_edit.is_staff = request.POST.get('user_is_staff', False) - user_edit.is_superuser = request.POST.get('user_is_superuser', False) - user_edit.save() - - userattributes = user_edit.userattributes - userattributes.can_clone_instances = request.POST.get('userattributes_can_clone_instances', False) - userattributes.max_instances = request.POST.get('userattributes_max_instances', 0) - userattributes.max_cpus = request.POST.get('userattributes_max_cpus', 0) - userattributes.max_memory = request.POST.get('userattributes_max_memory', 0) - userattributes.max_disk_size = request.POST.get('userattributes_max_disk_size', 0) - userattributes.save() - return HttpResponseRedirect(request.get_full_path()) - if 'block' in request.POST: - user_id = request.POST.get('user_id', '') - user_block = User.objects.get(id=user_id) - user_block.is_active = False - user_block.save() - return HttpResponseRedirect(request.get_full_path()) - if 'unblock' in request.POST: - user_id = request.POST.get('user_id', '') - user_unblock = User.objects.get(id=user_id) - user_unblock.is_active = True - user_unblock.save() - return HttpResponseRedirect(request.get_full_path()) - if 'delete' in request.POST: - user_id = request.POST.get('user_id', '') - try: - del_user_inst = UserInstance.objects.filter(user_id=user_id) - del_user_inst.delete() - finally: - user_delete = User.objects.get(id=user_id) - user_delete.delete() - return HttpResponseRedirect(request.get_full_path()) - - return render(request, 'accounts.html', locals()) + return render( + request, + "profile.html", + { + "publickeys": publickeys, + "profile_form": profile_form, + "ssh_key_form": ssh_key_form, + }, + ) -@login_required +def ssh_key_create(request): + key_form = UserSSHKeyForm(request.POST or None, user=request.user) + if key_form.is_valid(): + key_form.save() + messages.success(request, _("SSH key added")) + return redirect("accounts:profile") + + return render( + request, + "common/form.html", + { + "form": key_form, + "title": _("Add SSH key"), + }, + ) + + +def ssh_key_delete(request, pk): + ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user) + if request.method == "POST": + ssh_key.delete() + messages.success(request, _("SSH key deleted")) + return redirect("accounts:profile") + + return render( + request, + "common/confirm_delete.html", + { + "object": ssh_key, + "title": _("Delete SSH key"), + }, + ) + + +@superuser_only def account(request, user_id): - """ - :param request: - :return: - """ - - if not request.user.is_superuser: - return HttpResponseRedirect(reverse('index')) - - error_messages = [] user = User.objects.get(id=user_id) user_insts = UserInstance.objects.filter(user_id=user_id) - instances = Instance.objects.all().order_by('name') + instances = Instance.objects.all().order_by("name") publickeys = UserSSHKey.objects.filter(user_id=user_id) - if request.method == 'POST': - if 'delete' in request.POST: - user_inst = request.POST.get('user_inst', '') - del_user_inst = UserInstance.objects.get(id=user_inst) - del_user_inst.delete() - return HttpResponseRedirect(request.get_full_path()) - if 'permission' in request.POST: - user_inst = request.POST.get('user_inst', '') - inst_vnc = request.POST.get('inst_vnc', '') - inst_change = request.POST.get('inst_change', '') - inst_delete = request.POST.get('inst_delete', '') - edit_user_inst = UserInstance.objects.get(id=user_inst) - edit_user_inst.is_change = bool(inst_change) - edit_user_inst.is_delete = bool(inst_delete) - edit_user_inst.is_vnc = bool(inst_vnc) - edit_user_inst.save() - return HttpResponseRedirect(request.get_full_path()) - if 'add' in request.POST: - inst_id = request.POST.get('inst_id', '') - - if settings.ALLOW_INSTANCE_MULTIPLE_OWNER: - check_inst = UserInstance.objects.filter(instance_id=int(inst_id), user_id=int(user_id)) - else: - check_inst = UserInstance.objects.filter(instance_id=int(inst_id)) - - if check_inst: - msg = _("Instance already added") - error_messages.append(msg) - else: - add_user_inst = UserInstance(instance_id=int(inst_id), user_id=int(user_id)) - add_user_inst.save() - return HttpResponseRedirect(request.get_full_path()) + return render( + request, + "account.html", + { + "user": user, + "user_insts": user_insts, + "instances": instances, + "publickeys": publickeys, + "otp_enabled": settings.OTP_ENABLED, + }, + ) - return render(request, 'account.html', locals()) + +@permission_required("accounts.change_password", raise_exception=True) +def change_password(request): + form = PasswordChangeForm(request.user, request.POST or None) + + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) # Important! + messages.success(request, _("Password Changed")) + return redirect("accounts:profile") + + return render(request, "accounts/change_password_form.html", {"form": form}) + + +@superuser_only +def user_instance_create(request, user_id): + user = get_object_or_404(User, pk=user_id) + + form = forms.UserInstanceForm(request.POST or None, initial={"user": user}) + if form.is_valid(): + form.save() + return redirect(reverse("accounts:account", args=[user.id])) + + return render( + request, + "common/form.html", + { + "form": form, + "title": _("Create User Instance"), + }, + ) + + +@superuser_only +def user_instance_update(request, pk): + user_instance = get_object_or_404(UserInstance, pk=pk) + form = forms.UserInstanceForm(request.POST or None, instance=user_instance) + if form.is_valid(): + form.save() + return redirect(reverse("accounts:account", args=[user_instance.user.id])) + + return render( + request, + "common/form.html", + { + "form": form, + "title": _("Update User Instance"), + }, + ) + + +@superuser_only +def user_instance_delete(request, pk): + user_instance = get_object_or_404(UserInstance, pk=pk) + if request.method == "POST": + user = user_instance.user + user_instance.delete() + next = request.GET.get("next", None) + if next: + return redirect(next) + else: + return redirect(reverse("accounts:account", args=[user.id])) + + return render( + request, + "common/confirm_delete.html", + {"object": user_instance}, + ) + + +def email_otp(request): + form = EmailOTPForm(request.POST or None) + if form.is_valid(): + UserModel = get_user_model() + try: + user = UserModel.objects.get(email=form.cleaned_data["email"]) + except UserModel.DoesNotExist: + pass + else: + device = get_user_totp_device(user) + send_email_with_otp(user, device) + + messages.success(request, _("OTP Sent to %(email)s") % {"email": form.cleaned_data["email"]}) + return redirect("accounts:login") + + return render( + request, + "accounts/email_otp_form.html", + { + "form": form, + "title": _("Email OTP"), + }, + ) + + +@superuser_only +def admin_email_otp(request, user_id): + user = get_object_or_404(get_user_model(), pk=user_id) + device = get_user_totp_device(user) + if user.email != "": + send_email_with_otp(user, device) + messages.success(request, _("OTP QR code was emailed to user %(user)s") % {"user": user}) + else: + messages.error(request, _("User email not set, failed to send QR code")) + return redirect("accounts:account", user.id) diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..b7f8a6a --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1 @@ +defautl_app_config = 'admin.apps.AdminConfig' diff --git a/admin/apps.py b/admin/apps.py new file mode 100644 index 0000000..5bbf122 --- /dev/null +++ b/admin/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AdminConfig(AppConfig): + name = 'admin' diff --git a/admin/decorators.py b/admin/decorators.py new file mode 100644 index 0000000..ebe901f --- /dev/null +++ b/admin/decorators.py @@ -0,0 +1,10 @@ +from django.core.exceptions import PermissionDenied + + +def superuser_only(function): + def _inner(request, *args, **kwargs): + if not request.user.is_superuser: + raise PermissionDenied + return function(request, *args, **kwargs) + + return _inner diff --git a/admin/forms.py b/admin/forms.py new file mode 100644 index 0000000..140e6a8 --- /dev/null +++ b/admin/forms.py @@ -0,0 +1,117 @@ +from django import forms +from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.contrib.auth.models import Group, User +from django.urls import reverse_lazy +from django.utils.text import format_lazy +from django.utils.translation import gettext_lazy as _ + +from accounts.models import UserAttributes + +from .models import Permission + + +class GroupForm(forms.ModelForm): + permissions = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + queryset=Permission.objects.filter(content_type__model="permissionset"), + required=False, + ) + + users = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + queryset=User.objects.all(), + required=False, + ) + + def __init__(self, *args, **kwargs): + super(GroupForm, self).__init__(*args, **kwargs) + instance = getattr(self, "instance", None) + if instance and instance.id: + self.fields["users"].initial = self.instance.user_set.all() + + def save_m2m(self): + self.instance.user_set.set(self.cleaned_data["users"]) + + def save(self, *args, **kwargs): + instance = super(GroupForm, self).save() + self.save_m2m() + return instance + + class Meta: + model = Group + fields = "__all__" + + +class UserForm(forms.ModelForm): + user_permissions = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + queryset=Permission.objects.filter(content_type__model="permissionset"), + label=_("Permissions"), + required=False, + ) + + groups = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + queryset=Group.objects.all(), + label=_("Groups"), + required=False, + ) + + class Meta: + model = User + fields = [ + "username", + "groups", + "first_name", + "last_name", + "email", + "user_permissions", + "is_staff", + "is_active", + "is_superuser", + ] + + def __init__(self, *args, **kwargs): + super(UserForm, self).__init__(*args, **kwargs) + if self.instance.id: + password = ReadOnlyPasswordHashField( + label=_("Password"), + help_text=format_lazy( + _( + """Raw passwords are not stored, so there is no way to see this user's password, + but you can change the password using this form.""" + ), + reverse_lazy( + "admin:user_update_password", + args=[ + self.instance.id, + ], + ), + ), + ) + self.fields["Password"] = password + + +class UserCreateForm(UserForm): + password = forms.CharField(widget=forms.PasswordInput) + + class Meta: + model = User + fields = [ + "username", + "password", + "groups", + "first_name", + "last_name", + "email", + "user_permissions", + "is_staff", + "is_active", + "is_superuser", + ] + + +class UserAttributesForm(forms.ModelForm): + class Meta: + model = UserAttributes + exclude = ["user", "can_clone_instances"] diff --git a/admin/migrations/0001_initial.py b/admin/migrations/0001_initial.py new file mode 100644 index 0000000..e558854 --- /dev/null +++ b/admin/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.12 on 2020-05-27 07:01 + +import django.contrib.auth.models +from django.db import migrations + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Permission', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.permission',), + managers=[ + ('objects', django.contrib.auth.models.PermissionManager()), + ], + ), + ] diff --git a/admin/migrations/0002_auto_20200609_0830.py b/admin/migrations/0002_auto_20200609_0830.py new file mode 100644 index 0000000..012a070 --- /dev/null +++ b/admin/migrations/0002_auto_20200609_0830.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.12 on 2020-06-09 08:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin', '0001_initial'), + ('auth', '0011_update_proxy_permissions'), + ] + + operations = [ + ] diff --git a/create/__init__.py b/admin/migrations/__init__.py similarity index 100% rename from create/__init__.py rename to admin/migrations/__init__.py diff --git a/admin/models.py b/admin/models.py new file mode 100644 index 0000000..b743377 --- /dev/null +++ b/admin/models.py @@ -0,0 +1,13 @@ +from django.contrib.auth.models import Permission as P + + +class Permission(P): + """ + Proxy model to Django Permissions model allows us to override __str__ + """ + + def __str__(self): + return f'{self.content_type.app_label}: {self.name}' + + class Meta: + proxy = True diff --git a/admin/templates/admin/group_list.html b/admin/templates/admin/group_list.html new file mode 100644 index 0000000..d101466 --- /dev/null +++ b/admin/templates/admin/group_list.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load icons %} +{% block title %}{% trans "Users" %}{% endblock %} +{% block content %} +
+
+ + {% icon 'plus' %} + + +

{% trans "Groups" %}

+
+
+
+ {% if not groups %} +
+
+ + {% icon 'exclamation-triangle '%} {% trans "Warning" %}: {% trans "You don't have any groups" %} +
+
+ {% else %} +
+ + + + + + + + + {% for group in groups %} + + + + + {% endfor %} + +
{% trans "Group Name" %}{% trans "Actions" %}
+ {{ group.name }} + + +
+
+ {% endif %} +
+{% endblock content %} + +{% block script %} + +{% endblock script %} diff --git a/admin/templates/admin/logs.html b/admin/templates/admin/logs.html new file mode 100644 index 0000000..906fa21 --- /dev/null +++ b/admin/templates/admin/logs.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% load i18n %} +{% load bootstrap4 %} + +{% block title %}{% trans "Logs" %}{% endblock %} + +{% block page_heading %}{% trans "Logs" %}{% endblock page_heading %} + +{% block content %} +
+
+ {% if not logs %} +
+
+ + {% trans "Warning" %}: {% trans "You don't have any Logs" %} +
+
+ {% else %} +
+ + + + + + + + + + + + {% for log in logs %} + + + + + + + + {% endfor %} + +
#{% trans "Date" %}{% trans "User" %}{% trans "Instance" %}{% trans "Message" %}
{{ log.id }}{{ log.date|date:"M d H:i:s" }}{{ log.user }}{{ log.instance }}{{ log.message }}
+
+ {% bootstrap_pagination logs %} + {% endif %} +
+
+{% endblock %} diff --git a/admin/templates/admin/user_form.html b/admin/templates/admin/user_form.html new file mode 100644 index 0000000..76a5656 --- /dev/null +++ b/admin/templates/admin/user_form.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% load bootstrap4 %} +{% load icons %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} + +{% block page_heading %}{{ title }}{% endblock page_heading %} + +{% block content %} +
+
+
+ {% csrf_token %} + {% bootstrap_form user_form layout='horizontal' %} + {% bootstrap_form attributes_form layout='horizontal' %} +
+
+ {% icon 'times' %} {% trans "Cancel" %} + +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/admin/templates/admin/user_list.html b/admin/templates/admin/user_list.html new file mode 100644 index 0000000..f1a6323 --- /dev/null +++ b/admin/templates/admin/user_list.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load common_tags %} +{% load icons %} + +{% block title %}{{ title }}{% endblock %} + +{% block page_heading %}{{ title }}{% endblock page_heading %} + +{% block page_heading_extra %} + + {% icon 'plus' %} + + +{% endblock page_heading_extra %} + +{% block content %} +
+ {% if not users %} +
+
+ + {% icon 'exclamation-triangle '%} {% trans "Warning" %}: {% trans "You don't have any user" %} +
+
+ {% else %} +
+ + + + + + + + + + + + + {% for user in users %} + {% has_perm user 'instances.clone_instances' as can_clone %} + + + + + + + + + {% endfor %} + +
{% trans "Username" %}{% trans "Status" %}{% trans "Staff" %}{% trans "Superuser" %}{% trans "Can Clone" %}{% trans "Actions" %}
+ {{ user.username }} + + {% if user.is_active %} + {% trans "Active" %} + {% else %} + {% trans "Blocked" %} + {% endif %} + {% if user.is_staff %}{% icon 'check' %}{% endif %}{% if user.is_superuser %}{% icon 'check' %}{% endif %}{% if can_clone %}{% icon 'check' %}{% endif %} +
+ {% icon 'eye' %} + {% icon 'pencil' %} + {% if user.is_active %} + {% icon 'stop' %} + {% else %} + {% icon 'play' %} + {% endif %} + {% icon 'times' %} +
+
+
+ {% endif %} +
+{% endblock content %} + +{% block script %} + +{% endblock script %} diff --git a/admin/tests.py b/admin/tests.py new file mode 100644 index 0000000..b030436 --- /dev/null +++ b/admin/tests.py @@ -0,0 +1,120 @@ +from django.contrib.auth.models import Group, User +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import reverse +from django.test import TestCase + +from accounts.models import UserAttributes + + +class AdminTestCase(TestCase): + def setUp(self): + self.client.login(username='admin', password='admin') + + def test_group_list(self): + response = self.client.get(reverse('admin:group_list')) + self.assertEqual(response.status_code, 200) + + def test_groups(self): + response = self.client.get(reverse('admin:group_create')) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('admin:group_create'), {'name': 'Test Group'}) + self.assertRedirects(response, reverse('admin:group_list')) + + group = Group.objects.get(name='Test Group') + self.assertEqual(group.id, 1) + + response = self.client.get(reverse('admin:group_update', args=[1])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('admin:group_update', args=[1]), {'name': 'Updated Group Test'}) + self.assertRedirects(response, reverse('admin:group_list')) + + group = Group.objects.get(id=1) + self.assertEqual(group.name, 'Updated Group Test') + + response = self.client.get(reverse('admin:group_delete', args=[1])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('admin:group_delete', args=[1])) + self.assertRedirects(response, reverse('admin:group_list')) + + with self.assertRaises(ObjectDoesNotExist): + Group.objects.get(id=1) + + def test_user_list(self): + response = self.client.get(reverse('admin:user_list')) + self.assertEqual(response.status_code, 200) + + def test_users(self): + response = self.client.get(reverse('admin:user_create')) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('admin:user_create'), + { + 'username': 'test', + 'password': 'test', + 'max_instances': 1, + 'max_cpus': 1, + 'max_memory': 1024, + 'max_disk_size': 4, + }, + ) + self.assertRedirects(response, reverse('admin:user_list')) + + user = User.objects.get(username='test') + self.assertEqual(user.id, 2) + + ua: UserAttributes = UserAttributes.objects.get(id=2) + self.assertEqual(ua.user_id, 2) + self.assertEqual(ua.max_instances, 1) + self.assertEqual(ua.max_cpus, 1) + self.assertEqual(ua.max_memory, 1024) + self.assertEqual(ua.max_disk_size, 4) + + response = self.client.get(reverse('admin:user_update', args=[2])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('admin:user_update', args=[2]), + { + 'username': 'utest', + 'max_instances': 2, + 'max_cpus': 2, + 'max_memory': 2048, + 'max_disk_size': 8, + }, + ) + self.assertRedirects(response, reverse('admin:user_list')) + + user = User.objects.get(id=2) + self.assertEqual(user.username, 'utest') + + ua: UserAttributes = UserAttributes.objects.get(id=2) + self.assertEqual(ua.user_id, 2) + self.assertEqual(ua.max_instances, 2) + self.assertEqual(ua.max_cpus, 2) + self.assertEqual(ua.max_memory, 2048) + self.assertEqual(ua.max_disk_size, 8) + + response = self.client.get(reverse('admin:user_block', args=[2])) + user = User.objects.get(id=2) + self.assertFalse(user.is_active) + + response = self.client.get(reverse('admin:user_unblock', args=[2])) + user = User.objects.get(id=2) + self.assertTrue(user.is_active) + + response = self.client.get(reverse('admin:user_delete', args=[2])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('admin:user_delete', args=[2])) + self.assertRedirects(response, reverse('admin:user_list')) + + with self.assertRaises(ObjectDoesNotExist): + User.objects.get(id=2) + + def test_logs(self): + response = self.client.get(reverse('admin:logs')) + self.assertEqual(response.status_code, 200) diff --git a/admin/urls.py b/admin/urls.py new file mode 100644 index 0000000..205cbc0 --- /dev/null +++ b/admin/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('groups/', views.group_list, name='group_list'), + path('groups/create/', views.group_create, name='group_create'), + path('groups//update/', views.group_update, name='group_update'), + path('groups//delete/', views.group_delete, name='group_delete'), + path('users/', views.user_list, name='user_list'), + path('users/create/', views.user_create, name='user_create'), + path('users//update_password/', views.user_update_password, name='user_update_password'), + path('users//update/', views.user_update, name='user_update'), + path('users//delete/', views.user_delete, name='user_delete'), + path('users//block/', views.user_block, name='user_block'), + path('users//unblock/', views.user_unblock, name='user_unblock'), + path('logs/', views.logs, name='logs'), +] diff --git a/admin/views.py b/admin/views.py new file mode 100644 index 0000000..c920890 --- /dev/null +++ b/admin/views.py @@ -0,0 +1,206 @@ +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import update_session_auth_hash +from django.contrib.auth.forms import AdminPasswordChangeForm +from django.contrib.auth.models import Group, User +from django.core.paginator import Paginator +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.translation import gettext_lazy as _ + +from accounts.models import UserAttributes, UserInstance, Instance +from appsettings.settings import app_settings +from logs.models import Logs + +from . import forms +from .decorators import superuser_only + + +@superuser_only +def group_list(request): + groups = Group.objects.all() + return render( + request, + "admin/group_list.html", + { + "groups": groups, + }, + ) + + +@superuser_only +def group_create(request): + form = forms.GroupForm(request.POST or None) + if form.is_valid(): + form.save() + return redirect("admin:group_list") + + return render( + request, + "common/form.html", + { + "form": form, + "title": _("Create Group"), + }, + ) + + +@superuser_only +def group_update(request, pk): + group = get_object_or_404(Group, pk=pk) + form = forms.GroupForm(request.POST or None, instance=group) + if form.is_valid(): + form.save() + return redirect("admin:group_list") + + return render( + request, + "common/form.html", + { + "form": form, + "title": _("Update Group"), + }, + ) + + +@superuser_only +def group_delete(request, pk): + group = get_object_or_404(Group, pk=pk) + if request.method == "POST": + group.delete() + return redirect("admin:group_list") + + return render( + request, + "common/confirm_delete.html", + {"object": group}, + ) + + +@superuser_only +def user_list(request): + users = User.objects.all() + return render( + request, + "admin/user_list.html", + { + "users": users, + "title": _("Users"), + }, + ) + + +@superuser_only +def user_create(request): + user_form = forms.UserCreateForm(request.POST or None) + attributes_form = forms.UserAttributesForm(request.POST or None) + if user_form.is_valid() and attributes_form.is_valid(): + user = user_form.save() + password = user_form.cleaned_data["password"] + user.set_password(password) + user.save() + attributes = attributes_form.save(commit=False) + attributes.user = user + attributes.save() + add_default_instances(user) + return redirect("admin:user_list") + + return render( + request, + "admin/user_form.html", + {"user_form": user_form, "attributes_form": attributes_form, "title": _("Create User")}, + ) + + +@superuser_only +def user_update(request, pk): + user = get_object_or_404(User, pk=pk) + attributes = UserAttributes.objects.get(user=user) + user_form = forms.UserForm(request.POST or None, instance=user) + attributes_form = forms.UserAttributesForm(request.POST or None, instance=attributes) + if user_form.is_valid() and attributes_form.is_valid(): + user_form.save() + attributes_form.save() + next = request.GET.get("next") + return redirect(next or "admin:user_list") + + return render( + request, + "admin/user_form.html", + {"user_form": user_form, "attributes_form": attributes_form, "title": _("Update User")}, + ) + + +@superuser_only +def user_update_password(request, pk): + user = get_object_or_404(User, pk=pk) + if request.method == "POST": + form = AdminPasswordChangeForm(user, request.POST) + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) # Important! + messages.success(request, _("Password changed for %(user)s") % {"user": user.username}) + return redirect("admin:user_list") + else: + messages.error(request, _("Wrong Data Provided")) + else: + form = AdminPasswordChangeForm(user) + + return render( + request, + "accounts/change_password_form.html", + { + "form": form, + "user": user.username, + }, + ) + + +@superuser_only +def user_delete(request, pk): + user = get_object_or_404(User, pk=pk) + if request.method == "POST": + user.delete() + return redirect("admin:user_list") + + return render( + request, + "common/confirm_delete.html", + {"object": user}, + ) + + +@superuser_only +def user_block(request, pk): + user: User = get_object_or_404(User, pk=pk) + user.is_active = False + user.save() + return redirect("admin:user_list") + + +@superuser_only +def user_unblock(request, pk): + user: User = get_object_or_404(User, pk=pk) + user.is_active = True + user.save() + return redirect("admin:user_list") + + +@superuser_only +def logs(request): + l = Logs.objects.order_by("-date") + paginator = Paginator(l, int(app_settings.LOGS_PER_PAGE)) + page = request.GET.get("page", 1) + logs = paginator.page(page) + return render(request, "admin/logs.html", {"logs": logs}) + + +def add_default_instances(user): + """ + Adds instances listed in NEW_USER_DEFAULT_INSTANCES to user + """ + existing_instances = UserInstance.objects.filter(user=user) + if not existing_instances: + for instance_name in settings.NEW_USER_DEFAULT_INSTANCES: + instance = Instance.objects.get(name=instance_name) + user_instance = UserInstance(user=user, instance=instance) + user_instance.save() diff --git a/create/migrations/__init__.py b/appsettings/__init__.py similarity index 100% rename from create/migrations/__init__.py rename to appsettings/__init__.py diff --git a/appsettings/apps.py b/appsettings/apps.py new file mode 100644 index 0000000..2e5909e --- /dev/null +++ b/appsettings/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AppsettingsConfig(AppConfig): + name = 'appsettings' diff --git a/appsettings/context_processors.py b/appsettings/context_processors.py new file mode 100644 index 0000000..5991630 --- /dev/null +++ b/appsettings/context_processors.py @@ -0,0 +1,13 @@ +from .settings import app_settings as settings + + +def app_settings(request): + """ + Simple context processor that puts the config into every\ + RequestContext. Just make sure you have a setting like this:: + TEMPLATE_CONTEXT_PROCESSORS = ( + # ... + 'appsettings.context_processors.app_settings', + ) + """ + return {"app_settings": settings} diff --git a/appsettings/middleware.py b/appsettings/middleware.py new file mode 100644 index 0000000..737477d --- /dev/null +++ b/appsettings/middleware.py @@ -0,0 +1,10 @@ +from .settings import app_settings, get_settings + + +class AppSettingsMiddleware(object): + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + get_settings() + return self.get_response(request) diff --git a/appsettings/migrations/0001_initial.py b/appsettings/migrations/0001_initial.py new file mode 100644 index 0000000..bcf4376 --- /dev/null +++ b/appsettings/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.12 on 2020-05-27 16:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AppSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=25)), + ('key', models.CharField(db_index=True, max_length=50, unique=True)), + ('value', models.CharField(max_length=25)), + ('choices', models.CharField(max_length=70)), + ('description', models.CharField(max_length=100, null=True)), + ], + ), + ] diff --git a/appsettings/migrations/0002_auto_20200527_1603.py b/appsettings/migrations/0002_auto_20200527_1603.py new file mode 100644 index 0000000..f064c47 --- /dev/null +++ b/appsettings/migrations/0002_auto_20200527_1603.py @@ -0,0 +1,79 @@ +# Generated by Django 2.2.12 on 2020-05-23 12:05 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + + +def add_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).bulk_create([ + setting(1, _("Theme"), "BOOTSTRAP_THEME", "flaty", "", _("Bootstrap CSS & Bootswatch Theme")), + setting(2, _("Theme SASS Path"), "SASS_DIR", "dev/scss/", "", _("Bootstrap SASS & Bootswatch SASS Directory")), + setting(3, _("All Instances View Style"), "VIEW_INSTANCES_LIST_STYLE", "grouped", "grouped,nongrouped", _("All instances list style")), + setting(4, _("Logs per Page"), "LOGS_PER_PAGE", "100", "", _("Pagination for logs")), + setting(5, _("Multiple Owner for VM"), "ALLOW_INSTANCE_MULTIPLE_OWNER", "True", "True,False", _("Allow to have multiple owner for instance")), + setting(6, _("Quota Debug"), "QUOTA_DEBUG", "True", "True,False", _("Debug for user quotas")), + setting(7, _("Disk Format"), "INSTANCE_VOLUME_DEFAULT_FORMAT", "qcow2", "raw,qcow,qcow2", _("Instance disk format")), + setting(8, _("Disk Bus"), "INSTANCE_VOLUME_DEFAULT_BUS", "virtio", "virtio,scsi,ide,usb,sata", _("Instance disk bus type")), + setting(9, _("Disk SCSI Controller"), "INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER", "virtio-scsi", "virtio-scsi, lsilogic, virtio-blk", _("SCSI controller type")), + setting(10, _("Disk Cache"), "INSTANCE_VOLUME_DEFAULT_CACHE", "directsync", "default,directsync,none,unsafe,writeback,writethrough", _("Disk volume cache type")), + setting(11, _("Disk IO Type"), "INSTANCE_VOLUME_DEFAULT_IO", "default", "default,native,threads", _("Volume io modes")), + setting(12, _("Disk Detect Zeroes"), "INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES", "default", "default,on,off,unmap", _("Volume detect zeroes mode")), + setting(13, _("Disk Discard"), "INSTANCE_VOLUME_DEFAULT_DISCARD", "default", "default,unmap,ignore", _("Volume discard mode")), + setting(14, _("Disk Owner UID"), "INSTANCE_VOLUME_DEFAULT_OWNER_UID", "0", "", _("Owner UID: up to os, 0=root, 107=qemu or libvirt-bin(for ubuntu)")), + setting(15, _("Disk Owner GID"), "INSTANCE_VOLUME_DEFAULT_OWNER_GID", "0", "", _("Owner GID: up to os, 0=root, 107=qemu or libvirt-bin(for ubuntu)")), + setting(16, _("VM CPU Mode"), "INSTANCE_CPU_DEFAULT_MODE", "host-model", "no-model,host-model,host-passthrough,custom", _("Cpu modes")), + setting(17, _("VM Machine Type"), "INSTANCE_MACHINE_DEFAULT_TYPE", "q35", "q35,x86_64", _("Chipset/Machine type")), + setting(18, _("VM Firmware Type"), "INSTANCE_FIRMWARE_DEFAULT_TYPE", "BIOS", "BIOS,UEFI", _("Firmware type for x86_64")), + setting(19, _("VM Architecture Type"), "INSTANCE_ARCH_DEFAULT_TYPE", "x86_64", "x86_64,i686", _("Architecture type: x86_64, i686, etc")), + setting(20, _("VM Console Type"), "QEMU_CONSOLE_DEFAULT_TYPE", "vnc", "vnc,spice", _("Default console type")), + setting(21, _("VM Clone Name Prefix"), "CLONE_INSTANCE_DEFAULT_PREFIX", "instance", "True,False", _("Prefix for cloned instance name")), + setting(22, _("VM Clone Auto Name"), "CLONE_INSTANCE_AUTO_NAME", "False", "True,False", _("Generated name for cloned instance")), + setting(23, _("VM Clone Auto Migrate"), "CLONE_INSTANCE_AUTO_MIGRATE", "False", "True,False", _("Auto migrate instance after clone")), + setting(24, _("VM Bottom Bar"), "VIEW_INSTANCE_DETAIL_BOTTOM_BAR", "True", "True,False", _("Bottom navbar for instance details")), + setting(25, _("Show Access Root Pass"), "SHOW_ACCESS_ROOT_PASSWORD", "False", "True,False", _("Show access root password")), + setting(26, _("Show Access SSH Keys"), "SHOW_ACCESS_SSH_KEYS", "False", "True,False", _("Show access ssh keys")), + ]) + + +def del_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).filter(key="QEMU_CONSOLE_DEFAULT_TYPE").delete() + setting.objects.using(db_alias).filter(key="ALLOW_INSTANCE_MULTIPLE_OWNER").delete() + setting.objects.using(db_alias).filter(key="CLONE_INSTANCE_DEFAULT_PREFIX").delete() + setting.objects.using(db_alias).filter(key="CLONE_INSTANCE_AUTO_NAME").delete() + setting.objects.using(db_alias).filter(key="CLONE_INSTANCE_AUTO_MIGRATE").delete() + setting.objects.using(db_alias).filter(key="LOGS_PER_PAGE").delete() + setting.objects.using(db_alias).filter(key="QUOTA_DEBUG").delete() + setting.objects.using(db_alias).filter(key="VIEW_INSTANCES_LIST_STYLE").delete() + setting.objects.using(db_alias).filter(key="VIEW_INSTANCE_DETAIL_BOTTOM_BAR").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_FORMAT").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_BUS").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_CACHE").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_IO").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_DISCARD").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_OWNER_UID").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_OWNER_GID").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_CPU_DEFAULT_MODE").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_MACHINE_DEFAULT_TYPE").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_FIRMWARE_DEFAULT_TYPE").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_ARCH_DEFAULT_TYPE").delete() + setting.objects.using(db_alias).filter(key="BOOTSTRAP_THEME").delete() + setting.objects.using(db_alias).filter(key="SASS_DIR").delete() + setting.objects.using(db_alias).filter(key="SHOW_ACCESS_ROOT_PASSWORD").delete() + setting.objects.using(db_alias).filter(key="SHOW_ACCESS_SSH_KEYS").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0001_initial'), + ] + + operations = [ + migrations.RunPython(add_default_settings, del_default_settings), + ] diff --git a/appsettings/migrations/0003_auto_20200615_0637.py b/appsettings/migrations/0003_auto_20200615_0637.py new file mode 100644 index 0000000..914e031 --- /dev/null +++ b/appsettings/migrations/0003_auto_20200615_0637.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.13 on 2020-06-15 06:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0002_auto_20200527_1603'), + ] + + operations = [ + migrations.AlterField( + model_name='appsettings', + name='choices', + field=models.CharField(max_length=70, verbose_name='choices'), + ), + migrations.AlterField( + model_name='appsettings', + name='description', + field=models.CharField(max_length=100, null=True, verbose_name='description'), + ), + migrations.AlterField( + model_name='appsettings', + name='key', + field=models.CharField(db_index=True, max_length=50, unique=True, verbose_name='key'), + ), + migrations.AlterField( + model_name='appsettings', + name='name', + field=models.CharField(max_length=25, verbose_name='name'), + ), + migrations.AlterField( + model_name='appsettings', + name='value', + field=models.CharField(max_length=25, verbose_name='value'), + ), + ] diff --git a/appsettings/migrations/0004_auto_20200716_0637.py b/appsettings/migrations/0004_auto_20200716_0637.py new file mode 100644 index 0000000..5593e83 --- /dev/null +++ b/appsettings/migrations/0004_auto_20200716_0637.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.13 on 2020-07-16 06:37 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + + +def add_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).bulk_create([ + setting(27, _("Console Scale"), "CONSOLE_SCALE", "False", "True,False", _("Allow console to scaling view")), + setting(28, _("Console View-Only"), "CONSOLE_VIEW_ONLY", "False", "True,False", _("Allow only view not modify")), + setting(29, _("Console Resize Session"), "CONSOLE_RESIZE_SESSION", "False", "True,False", _("Allow to resize session for console")), + setting(30, _("Console Clip Viewport"), "CONSOLE_CLIP_VIEWPORT", "False", "True,False", _("Clip console viewport")), + ]) + + +def del_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).filter(key="CONSOLE_SCALE").delete() + setting.objects.using(db_alias).filter(key="CONSOLE_VIEW_ONLY").delete() + setting.objects.using(db_alias).filter(key="CONSOLE_RESIZE_SESSION").delete() + setting.objects.using(db_alias).filter(key="CONSOLE_CLIP_VIEWPORT").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0003_auto_20200615_0637'), + ] + + operations = [ + migrations.RunPython(add_default_settings, del_default_settings), + ] diff --git a/appsettings/migrations/0005_auto_20200911_1233.py b/appsettings/migrations/0005_auto_20200911_1233.py new file mode 100644 index 0000000..ac57da7 --- /dev/null +++ b/appsettings/migrations/0005_auto_20200911_1233.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.14 on 2020-09-11 12:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0004_auto_20200716_0637'), + ] + + operations = [ + migrations.AlterField( + model_name='appsettings', + name='choices', + field=models.CharField(max_length=70, verbose_name='choices'), + ), + ] diff --git a/instances/templatetags/__init__.py b/appsettings/migrations/__init__.py similarity index 100% rename from instances/templatetags/__init__.py rename to appsettings/migrations/__init__.py diff --git a/appsettings/models.py b/appsettings/models.py new file mode 100644 index 0000000..a45827f --- /dev/null +++ b/appsettings/models.py @@ -0,0 +1,14 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class AppSettings(models.Model): + + def choices_as_list(self): + return self.choices.split(',') + + name = models.CharField(_('name'), max_length=25, null=False) + key = models.CharField(_('key'), db_index=True, max_length=50, unique=True) + value = models.CharField(_('value'), max_length=25) + choices = models.CharField(_('choices'), max_length=70) + description = models.CharField(_('description'), max_length=100, null=True) diff --git a/appsettings/settings.py b/appsettings/settings.py new file mode 100644 index 0000000..d575f37 --- /dev/null +++ b/appsettings/settings.py @@ -0,0 +1,18 @@ +from .models import AppSettings + + +class Settings: + pass + + +app_settings = Settings() + + +def get_settings(): + try: + entries = AppSettings.objects.all() + except: + pass + + for entry in entries: + setattr(app_settings, entry.key, entry.value) diff --git a/appsettings/templates/appsettings.html b/appsettings/templates/appsettings.html new file mode 100644 index 0000000..659afb7 --- /dev/null +++ b/appsettings/templates/appsettings.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Edit Settings" %}{% endblock title %} + +{% block page_heading %}{% trans "Edit Settings" %}{% endblock page_heading %} + +{% block content %} +
+
+ +
{% csrf_token %} +
+ + +
+ +
+
+
+ {% if request.user.is_superuser %} +
{% csrf_token %} +
+ +
+ +
+
+
+
{% csrf_token %} +
+ +
+ + {% trans "After change please full refresh page with 'Ctrl + F5' "%} +
+
+
+ {% endif %} + + {% for setting in appsettings %} +
{% csrf_token %} +
+ +
+ {% if setting.choices %} + + {% else %} + + {% endif%} +
+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/create/tests.py b/appsettings/tests.py similarity index 100% rename from create/tests.py rename to appsettings/tests.py diff --git a/appsettings/views.py b/appsettings/views.py new file mode 100644 index 0000000..5446c9a --- /dev/null +++ b/appsettings/views.py @@ -0,0 +1,91 @@ +import os + +import sass +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.utils.translation import gettext_lazy as _ +from logs.views import addlogmsg + +from appsettings.models import AppSettings + + +@login_required +def appsettings(request): + """ + :param request: + :return: + """ + main_css = "wvc-main.min.css" + sass_dir = AppSettings.objects.get(key="SASS_DIR") + bootstrap_theme = AppSettings.objects.get(key="BOOTSTRAP_THEME") + try: + themes_list = os.listdir(sass_dir.value + "/wvc-theme") + except FileNotFoundError as err: + messages.error(request, err) + addlogmsg(request.user.username, "", err) + + # Bootstrap settings related with filesystems, because of that they are excluded from other settings + appsettings = AppSettings.objects.exclude(description__startswith="Bootstrap").order_by("name") + + if request.method == "POST": + if "SASS_DIR" in request.POST: + try: + sass_dir.value = request.POST.get("SASS_DIR", "") + sass_dir.save() + + msg = _("SASS directory path is changed. Now: %(dir)s") % {"dir": sass_dir.value} + messages.success(request, msg) + except Exception as err: + msg = err + messages.error(request, msg) + + addlogmsg(request.user.username, "", msg) + return HttpResponseRedirect(request.get_full_path()) + + if "BOOTSTRAP_THEME" in request.POST: + theme = request.POST.get("BOOTSTRAP_THEME", "") + scss_var = f"@import '{sass_dir.value}/wvc-theme/{theme}/variables';" + scss_bootswatch = f"@import '{sass_dir.value}/wvc-theme/{theme}/bootswatch';" + scss_boot = f"@import '{sass_dir.value}/bootstrap-overrides.scss';" + + try: + with open(sass_dir.value + "/wvc-main.scss", "w") as main: + main.write(scss_var + "\n" + scss_boot + "\n" + scss_bootswatch + "\n") + + css_compressed = sass.compile( + string=scss_var + "\n" + scss_boot + "\n" + scss_bootswatch, + output_style="compressed", + ) + with open("static/css/" + main_css, "w") as css: + css.write(css_compressed) + + bootstrap_theme.value = theme + bootstrap_theme.save() + + msg = _("Theme is changed. Now: %(theme)s") % {"theme": theme} + messages.success(request, msg) + except Exception as err: + msg = err + messages.error(request, msg) + + addlogmsg(request.user.username, "", msg) + return HttpResponseRedirect(request.get_full_path()) + + for setting in appsettings: + if setting.key in request.POST: + try: + setting.value = request.POST.get(setting.key, "") + setting.save() + + msg = _("%(setting)s is changed. Now: %(value)s") % {"setting": setting.name, "value": setting.value} + messages.success(request, msg) + except Exception as err: + msg = err + messages.error(request, msg) + + addlogmsg(request.user.username, "", msg) + return HttpResponseRedirect(request.get_full_path()) + + return render(request, "appsettings.html", locals()) diff --git a/computes/admin.py b/computes/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/computes/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/computes/forms.py b/computes/forms.py index 7dfcbe6..6927272 100644 --- a/computes/forms.py +++ b/computes/forms.py @@ -1,166 +1,45 @@ -import re from django import forms -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ +from vrtManager.connection import CONN_SOCKET, CONN_SSH, CONN_TCP, CONN_TLS + from computes.models import Compute - -class ComputeAddTcpForm(forms.Form): - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) - hostname = forms.CharField(error_messages={'required': _('No IP / Domain name has been entered')}, - max_length=100) - login = forms.CharField(error_messages={'required': _('No login has been entered')}, - max_length=100) - password = forms.CharField(error_messages={'required': _('No password has been entered')}, - max_length=100) - - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The host name must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The host name must not exceed 20 characters')) - try: - Compute.objects.get(name=name) - except Compute.DoesNotExist: - return name - raise forms.ValidationError(_('This host is already connected')) - - def clean_hostname(self): - hostname = self.cleaned_data['hostname'] - have_symbol = re.match('[^a-z0-9.-]+', hostname) - wrong_ip = re.match('^0.|^255.', hostname) - if have_symbol: - raise forms.ValidationError(_('Hostname must contain only numbers, or the domain name separated by "."')) - elif wrong_ip: - raise forms.ValidationError(_('Wrong IP address')) - try: - Compute.objects.get(hostname=hostname) - except Compute.DoesNotExist: - return hostname - raise forms.ValidationError(_('This host is already connected')) +from .validators import validate_hostname -class ComputeAddSshForm(forms.Form): - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) - hostname = forms.CharField(error_messages={'required': _('No IP / Domain name has been entered')}, - max_length=100) - login = forms.CharField(error_messages={'required': _('No login has been entered')}, - max_length=20) +class TcpComputeForm(forms.ModelForm): + hostname = forms.CharField(validators=[validate_hostname]) + type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_TCP) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The name of the host must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The name of the host must not exceed 20 characters')) - try: - Compute.objects.get(name=name) - except Compute.DoesNotExist: - return name - raise forms.ValidationError(_('This host is already connected')) - - def clean_hostname(self): - hostname = self.cleaned_data['hostname'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', hostname) - wrong_ip = re.match('^0.|^255.', hostname) - if have_symbol: - raise forms.ValidationError(_('Hostname must contain only numbers, or the domain name separated by "."')) - elif wrong_ip: - raise forms.ValidationError(_('Wrong IP address')) - try: - Compute.objects.get(hostname=hostname) - except Compute.DoesNotExist: - return hostname - raise forms.ValidationError(_('This host is already connected')) + class Meta: + model = Compute + widgets = {'password': forms.PasswordInput()} + fields = '__all__' -class ComputeAddTlsForm(forms.Form): - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) - hostname = forms.CharField(error_messages={'required': _('No IP / Domain name has been entered')}, - max_length=100) - login = forms.CharField(error_messages={'required': _('No login has been entered')}, - max_length=100) - password = forms.CharField(error_messages={'required': _('No password has been entered')}, - max_length=100) +class SshComputeForm(forms.ModelForm): + hostname = forms.CharField(validators=[validate_hostname], label=_("FQDN/IP")) + type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_SSH) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The host name must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The host name must not exceed 20 characters')) - try: - Compute.objects.get(name=name) - except Compute.DoesNotExist: - return name - raise forms.ValidationError(_('This host is already connected')) - - def clean_hostname(self): - hostname = self.cleaned_data['hostname'] - have_symbol = re.match('[^a-z0-9.-]+', hostname) - wrong_ip = re.match('^0.|^255.', hostname) - if have_symbol: - raise forms.ValidationError(_('Hostname must contain only numbers, or the domain name separated by "."')) - elif wrong_ip: - raise forms.ValidationError(_('Wrong IP address')) - try: - Compute.objects.get(hostname=hostname) - except Compute.DoesNotExist: - return hostname - raise forms.ValidationError(_('This host is already connected')) + class Meta: + model = Compute + exclude = ['password'] -class ComputeEditHostForm(forms.Form): - host_id = forms.CharField() - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) - hostname = forms.CharField(error_messages={'required': _('No IP / Domain name has been entered')}, - max_length=100) - login = forms.CharField(error_messages={'required': _('No login has been entered')}, - max_length=100) - password = forms.CharField(max_length=100) +class TlsComputeForm(forms.ModelForm): + hostname = forms.CharField(validators=[validate_hostname]) + type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_TLS) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The name of the host must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The name of the host must not exceed 20 characters')) - return name - - def clean_hostname(self): - hostname = self.cleaned_data['hostname'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', hostname) - wrong_ip = re.match('^0.|^255.', hostname) - if have_symbol: - raise forms.ValidationError(_('Hostname must contain only numbers, or the domain name separated by "."')) - elif wrong_ip: - raise forms.ValidationError(_('Wrong IP address')) - return hostname + class Meta: + model = Compute + widgets = {'password': forms.PasswordInput()} + fields = '__all__' -class ComputeAddSocketForm(forms.Form): - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) - details = forms.CharField(error_messages={'required': _('No details has been entred')}, - max_length=50) +class SocketComputeForm(forms.ModelForm): + hostname = forms.CharField(widget=forms.HiddenInput, initial='localhost') + type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_SOCKET) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The host name must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The host name must not exceed 20 characters')) - try: - Compute.objects.get(name=name) - except Compute.DoesNotExist: - return name - raise forms.ValidationError(_('This host is already connected')) + class Meta: + model = Compute + fields = ['name', 'details', 'hostname', 'type'] diff --git a/computes/migrations/0001_initial.py b/computes/migrations/0001_initial.py index e8d6139..c8f9183 100644 --- a/computes/migrations/0001_initial.py +++ b/computes/migrations/0001_initial.py @@ -1,11 +1,12 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +# Generated by Django 2.2.10 on 2020-01-28 07:01 -from django.db import models, migrations +from django.db import migrations, models class Migration(migrations.Migration): + initial = True + dependencies = [ ] @@ -13,15 +14,13 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Compute', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=20)), - ('hostname', models.CharField(max_length=20)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64)), + ('hostname', models.CharField(max_length=64)), ('login', models.CharField(max_length=20)), - ('password', models.CharField(max_length=14, null=True, blank=True)), + ('password', models.CharField(blank=True, max_length=14, null=True)), + ('details', models.CharField(blank=True, max_length=64, null=True)), ('type', models.IntegerField()), ], - options={ - }, - bases=(models.Model,), ), ] diff --git a/computes/migrations/0002_auto_20200529_1320.py b/computes/migrations/0002_auto_20200529_1320.py new file mode 100644 index 0000000..194d885 --- /dev/null +++ b/computes/migrations/0002_auto_20200529_1320.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.12 on 2020-05-29 13:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('computes', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='compute', + name='name', + field=models.CharField(max_length=64, unique=True), + ), + ] diff --git a/computes/migrations/0002_compute_details.py b/computes/migrations/0002_compute_details.py deleted file mode 100644 index 1e0fdf5..0000000 --- a/computes/migrations/0002_compute_details.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - -class Migration(migrations.Migration): - - dependencies = [ - ('computes', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='Compute', - name='details', - field=models.CharField(max_length=50, null=True, blank=True), - ), - ] diff --git a/computes/migrations/0003_auto_20200615_0637.py b/computes/migrations/0003_auto_20200615_0637.py new file mode 100644 index 0000000..34cd59a --- /dev/null +++ b/computes/migrations/0003_auto_20200615_0637.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.13 on 2020-06-15 06:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('computes', '0002_auto_20200529_1320'), + ] + + operations = [ + migrations.AlterField( + model_name='compute', + name='details', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='details'), + ), + migrations.AlterField( + model_name='compute', + name='hostname', + field=models.CharField(max_length=64, verbose_name='hostname'), + ), + migrations.AlterField( + model_name='compute', + name='login', + field=models.CharField(max_length=20, verbose_name='login'), + ), + migrations.AlterField( + model_name='compute', + name='name', + field=models.CharField(max_length=64, unique=True, verbose_name='name'), + ), + migrations.AlterField( + model_name='compute', + name='password', + field=models.CharField(blank=True, max_length=14, null=True, verbose_name='password'), + ), + ] diff --git a/computes/models.py b/computes/models.py index df9bf02..b1c3cd1 100644 --- a/computes/models.py +++ b/computes/models.py @@ -1,13 +1,61 @@ -from django.db import models +from django.db.models import CharField, IntegerField, Model +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from libvirt import virConnect + +from vrtManager.connection import connection_manager +from vrtManager.hostdetails import wvmHostDetails -class Compute(models.Model): - name = models.CharField(max_length=20) - hostname = models.CharField(max_length=20) - login = models.CharField(max_length=20) - password = models.CharField(max_length=14, blank=True, null=True) - details = models.CharField(max_length=50, null=True, blank=True) - type = models.IntegerField() +class Compute(Model): + name = CharField(_('name'), max_length=64, unique=True) + hostname = CharField(_('hostname'), max_length=64) + login = CharField(_('login'), max_length=20) + password = CharField(_('password'), max_length=14, blank=True, null=True) + details = CharField(_('details'), max_length=64, null=True, blank=True) + type = IntegerField() - def __unicode__(self): - return self.hostname + @cached_property + def status(self): + # return connection_manager.host_is_up(self.type, self.hostname) + # TODO: looks like socket has problems connecting via VPN + if isinstance(self.connection, virConnect): + return True + else: + return self.connection + + @cached_property + def connection(self): + try: + return connection_manager.get_connection( + self.hostname, + self.login, + self.password, + self.type, + ) + except Exception as e: + return e + + @cached_property + def proxy(self): + return wvmHostDetails( + self.hostname, + self.login, + self.password, + self.type, + ) + + @cached_property + def cpu_count(self): + return self.proxy.get_node_info()[3] + + @cached_property + def ram_size(self): + return self.proxy.get_node_info()[2] + + @cached_property + def ram_usage(self): + return self.proxy.get_memory_usage()['percent'] + + def __str__(self): + return self.name diff --git a/computes/templates/computes.html b/computes/templates/computes.html deleted file mode 100644 index 2ffc6f4..0000000 --- a/computes/templates/computes.html +++ /dev/null @@ -1,226 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block title %}{% trans "Computes" %}{% endblock %} -{% block content %} - -
-
- {% include 'create_comp_block.html' %} -

{% trans "Computes" %}

-
-
- - - {% include 'errors_block.html' %} - -
- {% if computes_info %} - {% for compute in computes_info %} -
-
-
- {% ifequal compute.status 1 %} -

- {{ compute.name }} - - - -

- {% else %} -

{{ compute.name }} - - - -

- {% endifequal %} -
-
-
-
-

{% trans "Status:" %}

-
-
- {% if compute.status %} -

{% trans "Connected" %}

- {% else %} -

{% trans "Not Connected" %}

- {% endif %} - {% if compute.details %} -

{% trans compute.details %}

- {% else %} -

{% trans "No details available" %}

- {% endif %} -
-
- - - - -
-
-
- {% endfor %} - {% else %} -
-
- - {% trans "Warning:" %} {% trans "Hypervisor doesn't have any Computes" %} -
-
- {% endif %} -
-{% endblock %} diff --git a/computes/templates/computes/form.html b/computes/templates/computes/form.html new file mode 100644 index 0000000..483a3d1 --- /dev/null +++ b/computes/templates/computes/form.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% load bootstrap4 %} +{% load icons %} +{% load i18n %} + +{% block title %}{% trans "Add Compute" %}{% endblock %} + +{% block content %} +
+
+ +
+
+
+
+
+ {% csrf_token %} + {% bootstrap_form form layout='horizontal' %} +
+
+ {% icon 'times' %} {% trans "Cancel" %} + +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/computes/templates/computes/instances.html b/computes/templates/computes/instances.html new file mode 100644 index 0000000..f69143f --- /dev/null +++ b/computes/templates/computes/instances.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} +{% load i18n %} +{% load staticfiles %} +{% load icons %} +{% block title %}{% trans "Instances" %} - {{ compute.name }}{% endblock %} +{% block style %} + +{% endblock %} +{% block page_heading %}{{ compute.name }} - {% trans "Instances" %}{% endblock page_heading %} + +{% block page_heading_extra %} + + {% icon 'plus' %} + +{% if instances %} + +{% endif %} +{% endblock page_heading_extra %} + +{% block content %} + +
+
+ {% if not instances %} +
+ {% icon 'exclamation-triangle' %} {% trans "Warning" %}: + {% trans "Hypervisor doesn't have any Instances" %} + +
+
+ {% else %} + + + + + + + + + + + + + {% for instance in instances %} + + + + + + + + + {% endfor %} + +
{% trans 'Name' %}
{% trans 'Description' %}
{% trans 'User' %}{% trans 'Status' %}{% trans 'VCPU' %}{% trans 'Memory' %}{% trans 'Actions' %}
+ + {{ instance.name }} + + + + {% if instance.userinstance_set.all.count > 0 %} + {{ instance.userinstance_set.all.0.user }} + {% if instance.userinstance_set.all.count > 1 %} + (+{{ instance.userinstance_set.all.count|add:"-1" }}) + {% endif %} + {% endif %} + + + {% if instance.proxy.instance.info.0 == 1 %}{% trans "Active" %}{% endif %} + {% if instance.proxy.instance.info.0 == 5 %}{% trans "Off" %}{% endif %} + {% if instance.proxy.instance.info.0 == 3 %}{% trans "Suspended" %}{% endif %} + {{ instance.proxy.instance.info.3 }}{% widthratio instance.proxy.instance.info.1 1024 1 %} MiB + {% include 'instance_actions.html' %} +
+ {% endif %} +
+
+{% endblock %} +{% block script %} + + +{% endblock %} \ No newline at end of file diff --git a/computes/templates/computes/list.html b/computes/templates/computes/list.html new file mode 100644 index 0000000..8e4b899 --- /dev/null +++ b/computes/templates/computes/list.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load common_tags %} +{% load icons %} +{% block title %}{% trans "Computes" %}{% endblock %} +{% block content %} +
+
+ {% include 'create_comp_block.html' %} + {% include 'search_block.html' %} + +
+
+
+ {% if not computes %} +
+
+ + {% icon 'exclamation-triangle '%} {% trans "Warning" %}: {% trans "You don't have any computes" %} +
+
+ {% else %} +
+ + + + + + + + + + + {% for compute in computes %} + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Status" %}{% trans "Details" %}{% trans "Actions" %}
+ {{ compute.name }} + + {% if compute.status is True %}{% trans "Connected" %}{% else %}{% trans "Not Connected" %}{% endif %} + + {{ compute.details|default:"" }} + +
+ {% if compute.status is True %} + {% icon 'eye' %} + {% else %} + {% icon 'eye' %} + {% endif %} + {% icon 'pencil' %} + {% icon 'times' %} +
+
+
+ {% endif %} +
+{% endblock content %} + +{% block script %} + +{% endblock script %} diff --git a/computes/templates/create_comp_block.html b/computes/templates/create_comp_block.html index 9e9a965..478fea4 100644 --- a/computes/templates/create_comp_block.html +++ b/computes/templates/create_comp_block.html @@ -1,167 +1,10 @@ {% load i18n %} -{% if request.user.is_superuser %} - - - - - - -{% endif %} +{% load bootstrap4 %} +{% load icons %} + diff --git a/computes/templates/overview.html b/computes/templates/overview.html index 8bfad6e..2894ebb 100644 --- a/computes/templates/overview.html +++ b/computes/templates/overview.html @@ -1,152 +1,249 @@ {% extends "base.html" %} {% load i18n %} {% load staticfiles %} +{% load icons %} + {% block title %}{% trans "Overview" %} - {{ compute.name }}{% endblock %} + +{% block page_heading %}{{ compute.name }}{% endblock page_heading %} + {% block content %} - -
-
-

{{ compute.name }}

- -
-
- + - {% include 'errors_block.html' %} - -
- -
-

{% trans "Hostname" %}

-

{% trans "Hypervisor" %}

-

{% trans "Memory" %}

-

{% trans "Architecture" %}

-

{% trans "Logical CPUs" %}

-

{% trans "Processor" %}

-

{% trans "Connection" %}

-

{% trans "Details" %}

-
-
-

{{ hostname }}

-

{{ hypervisor }}

-

{{ host_memory|filesizeformat }}

-

{{ host_arch }}

-

{{ logical_cpu }}

-

{{ model_cpu }}

-

{{ uri_conn }}

-

{{ compute.details }}

-
-
-
-
- -
-
-

{% trans "CPU utilization" %}

-
-
-
-
- -
-
-
+
+ +
+
{% trans "Hostname" %}
+
{{ hostname }}
+
{% trans "Hypervisors" %}
+
+
+
{% trans "Emulator" %}
+
{{ emulator }}
+
{% trans "Version" %}
+
+ {% trans 'Qemu' %} + {{ version }}   + {% trans 'Libvirt' %} + {{ lib_version }}   +
+
{% trans "Memory" %}
+
{{ host_memory|filesizeformat }}
+
{% trans "Architecture" %}
+
{{ host_arch }}
+
{% trans "Logical CPUs" %}
+
{{ logical_cpu }}
+
{% trans "Processor" %}
+
{{ model_cpu }}
+
{% trans "Connection" %}
+
{{ uri_conn }}
+
{% trans "Details" %}
+
{{ compute.details }}
+
+ + +
+
+
+
+ + {% trans "CPU Utilization" %} +
+ +
+
+ +
+
+
+ {% trans "RAM Utilization" %} +
+
+
+
{% endblock %} {% block script %} - + {% endblock %} diff --git a/computes/tests.py b/computes/tests.py index 7ce503c..488202d 100644 --- a/computes/tests.py +++ b/computes/tests.py @@ -1,3 +1,143 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import reverse from django.test import TestCase -# Create your tests here. +from .models import Compute + + +class ComputesTestCase(TestCase): + def setUp(self): + self.client.login(username='admin', password='admin') + Compute( + name='local', + hostname='localhost', + login='', + password='', + details='local', + type=4, + ).save() + + def test_index(self): + response = self.client.get(reverse('computes')) + self.assertEqual(response.status_code, 200) + + def test_create_update_delete(self): + response = self.client.get(reverse('add_socket_host')) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('add_socket_host'), + { + 'name': 'l1', + 'details': 'Created', + 'hostname': 'localhost', + 'type': 4, + }, + ) + self.assertRedirects(response, reverse('computes')) + + compute = Compute.objects.get(pk=2) + self.assertEqual(compute.name, 'l1') + self.assertEqual(compute.details, 'Created') + + response = self.client.get(reverse('compute_update', args=[2])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('compute_update', args=[2]), + { + 'name': 'l2', + 'details': 'Updated', + 'hostname': 'localhost', + 'type': 4, + }, + ) + self.assertRedirects(response, reverse('computes')) + + compute = Compute.objects.get(pk=2) + self.assertEqual(compute.name, 'l2') + self.assertEqual(compute.details, 'Updated') + + response = self.client.get(reverse('compute_delete', args=[2])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('compute_delete', args=[2])) + self.assertRedirects(response, reverse('computes')) + + with self.assertRaises(ObjectDoesNotExist): + Compute.objects.get(id=2) + + def test_overview(self): + response = self.client.get(reverse('overview', args=[1])) + self.assertEqual(response.status_code, 200) + + def test_graph(self): + response = self.client.get(reverse('compute_graph', args=[1])) + self.assertEqual(response.status_code, 200) + + def test_instances(self): + response = self.client.get(reverse('instances', args=[1])) + self.assertEqual(response.status_code, 200) + + def test_storages(self): + response = self.client.get(reverse('storages', args=[1])) + self.assertEqual(response.status_code, 200) + + def test_storage(self): + pass + + def test_default_storage_volumes(self): + response = self.client.get(reverse('volumes', kwargs={'compute_id': 1, 'pool': 'default'})) + self.assertEqual(response.status_code, 200) + + def test_default_storage(self): + response = self.client.get(reverse('storage', kwargs={'compute_id': 1, 'pool': 'default'})) + self.assertEqual(response.status_code, 200) + + def test_networks(self): + response = self.client.get(reverse('networks', args=[1])) + self.assertEqual(response.status_code, 200) + + def test_default_network(self): + response = self.client.get(reverse('network', kwargs={'compute_id': 1, 'pool': 'default'})) + self.assertEqual(response.status_code, 200) + + def test_interfaces(self): + response = self.client.get(reverse('interfaces', args=[1])) + self.assertEqual(response.status_code, 200) + + # TODO: add test for single interface + + def test_nwfilters(self): + response = self.client.get(reverse('nwfilters', args=[1])) + self.assertEqual(response.status_code, 200) + + # TODO: add test for single nwfilter + + def test_secrets(self): + response = self.client.get(reverse('secrets', args=[1])) + self.assertEqual(response.status_code, 200) + + # def test_create_instance_select_type(self): + # response = self.client.get(reverse('create_instance_select_type', args=[1])) + # self.assertEqual(response.status_code, 200) + + # TODO: create_instance + + def test_machines(self): + response = self.client.get(reverse('machines', kwargs={'compute_id': 1, 'arch': 'x86_64'})) + self.assertEqual(response.status_code, 200) + + def test_compute_disk_buses(self): + response = self.client.get( + reverse('buses', kwargs={ + 'compute_id': 1, + 'arch': 'x86_64', + 'machine': 'pc', + 'disk': 'disk', + })) + self.assertEqual(response.status_code, 200) + + def test_dom_capabilities(self): + response = self.client.get(reverse('domcaps', kwargs={'compute_id': 1, 'arch': 'x86_64', 'machine': 'pc'})) + self.assertEqual(response.status_code, 200) diff --git a/computes/urls.py b/computes/urls.py index 58a0497..6e43695 100644 --- a/computes/urls.py +++ b/computes/urls.py @@ -1,9 +1,47 @@ -from django.conf.urls import url -from . import views +from secrets.views import secrets + +from django.urls import include, path +# from instances.views import create_instance, create_instance_select_type +from interfaces.views import interface, interfaces +from networks.views import network, networks +from nwfilters.views import nwfilter, nwfilters +from storages.views import create_volume, get_volumes, storage, storages + +from . import forms, views urlpatterns = [ - url(r'^$', views.computes, name='computes'), - url(r'^overview/(?P[0-9]+)/$', views.overview, name='overview'), - url(r'^statistics/(?P[0-9]+)/$', - views.compute_graph, name='compute_graph'), + path('', views.computes, name='computes'), + path('add_tcp_host/', views.compute_create, {'FormClass': forms.TcpComputeForm}, name='add_tcp_host'), + path('add_ssh_host/', views.compute_create, {'FormClass': forms.SshComputeForm}, name='add_ssh_host'), + path('add_tls_host/', views.compute_create, {'FormClass': forms.TlsComputeForm}, name='add_tls_host'), + path('add_socket_host/', views.compute_create, {'FormClass': forms.SocketComputeForm}, name='add_socket_host'), + path( + '/', + include([ + path('', views.overview, name='overview'), + path('update/', views.compute_update, name='compute_update'), + path('delete/', views.compute_delete, name='compute_delete'), + path('statistics', views.compute_graph, name='compute_graph'), + path('instances/', views.instances, name='instances'), + path('storages/', storages, name='storages'), + path('storage//volumes/', get_volumes, name='volumes'), + path('storage//', storage, name='storage'), + path('storage//create_volume/', create_volume, name='create_volume'), + path('networks/', networks, name='networks'), + path('network//', network, name='network'), + path('interfaces/', interfaces, name='interfaces'), + path('interface//', interface, name='interface'), + path('nwfilters/', nwfilters, name='nwfilters'), + path('nwfilter//', nwfilter, name='nwfilter'), + path('secrets/', secrets, name='secrets'), + # path('create/', create_instance_select_type, name='create_instance_select_type'), + # path('create/archs//machines//', create_instance, name='create_instance'), + path('archs//machines/', views.get_compute_machine_types, name='machines'), + path( + 'archs//machines//disks//buses/', + views.get_compute_disk_buses, + name='buses', + ), + path('archs//machines//capabilities/', views.get_dom_capabilities, name='domcaps'), + ])), ] diff --git a/computes/utils.py b/computes/utils.py new file mode 100644 index 0000000..351c5ff --- /dev/null +++ b/computes/utils.py @@ -0,0 +1,13 @@ +from instances.models import Instance + + +def refresh_instance_database(compute): + domains = compute.proxy.wvm.listAllDomains() + domain_names = [d.name() for d in domains] + # Delete instances that're not on host from DB + Instance.objects.filter(compute=compute).exclude(name__in=domain_names).delete() + # Create instances that're on host but not in DB + names = Instance.objects.filter(compute=compute).values_list('name', flat=True) + for domain in domains: + if domain.name() not in names: + Instance(compute=compute, name=domain.name(), uuid=domain.UUIDString()).save() diff --git a/computes/validators.py b/computes/validators.py new file mode 100644 index 0000000..64458fc --- /dev/null +++ b/computes/validators.py @@ -0,0 +1,24 @@ +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +have_symbol = re.compile('[^a-zA-Z0-9._-]+') +wrong_ip = re.compile('^0.|^255.') +wrong_name = re.compile('[^a-zA-Z0-9._-]+') + + +def validate_hostname(value): + sym = have_symbol.match(value) + wip = wrong_ip.match(value) + + if sym: + raise ValidationError(_('Hostname must contain only numbers, or the domain name separated by "."')) + elif wip: + raise ValidationError(_('Wrong IP address')) + + +def validate_name(value): + have_symbol = wrong_name.match('[^a-zA-Z0-9._-]+') + if have_symbol: + raise ValidationError(_('The hostname must not contain any special characters')) diff --git a/computes/views.py b/computes/views.py index 13fb70d..7d9184b 100644 --- a/computes/views.py +++ b/computes/views.py @@ -1,223 +1,258 @@ -import time import json -from django.http import HttpResponse, HttpResponseRedirect -from django.core.urlresolvers import reverse -from django.shortcuts import render, get_object_or_404 -from django.contrib.auth.decorators import login_required -from computes.models import Compute -from instances.models import Instance -from accounts.models import UserInstance -from computes.forms import ComputeAddTcpForm, ComputeAddSshForm, ComputeEditHostForm, ComputeAddTlsForm, ComputeAddSocketForm -from vrtManager.hostdetails import wvmHostDetails -from vrtManager.connection import CONN_SSH, CONN_TCP, CONN_TLS, CONN_SOCKET, connection_manager + +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils import timezone from libvirt import libvirtError +from admin.decorators import superuser_only +from computes.forms import SocketComputeForm, SshComputeForm, TcpComputeForm, TlsComputeForm +from computes.models import Compute +from instances.models import Instance +from vrtManager.connection import ( + CONN_SOCKET, + CONN_SSH, + CONN_TCP, + CONN_TLS, + connection_manager, + wvmConnect, +) +from vrtManager.hostdetails import wvmHostDetails -@login_required +from . import utils + + +@superuser_only def computes(request): """ :param request: :return: """ - if not request.user.is_superuser: - return HttpResponseRedirect(reverse('index')) + computes = Compute.objects.filter().order_by("name") - def get_hosts_status(computes): - """ - Function return all hosts all vds on host - """ - compute_data = [] - for compute in computes: - compute_data.append({'id': compute.id, - 'name': compute.name, - 'hostname': compute.hostname, - 'status': connection_manager.host_is_up(compute.type, compute.hostname), - 'type': compute.type, - 'login': compute.login, - 'password': compute.password, - 'details': compute.details - }) - return compute_data - - error_messages = [] - computes = Compute.objects.filter().order_by('name') - computes_info = get_hosts_status(computes) - - if request.method == 'POST': - if 'host_del' in request.POST: - compute_id = request.POST.get('host_id', '') - try: - del_user_inst_on_host = UserInstance.objects.filter(instance__compute_id=compute_id) - del_user_inst_on_host.delete() - finally: - try: - del_inst_on_host = Instance.objects.filter(compute_id=compute_id) - del_inst_on_host.delete() - finally: - del_host = Compute.objects.get(id=compute_id) - del_host.delete() - return HttpResponseRedirect(request.get_full_path()) - if 'host_tcp_add' in request.POST: - form = ComputeAddTcpForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - new_tcp_host = Compute(name=data['name'], - hostname=data['hostname'], - type=CONN_TCP, - login=data['login'], - password=data['password']) - new_tcp_host.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if 'host_ssh_add' in request.POST: - form = ComputeAddSshForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - new_ssh_host = Compute(name=data['name'], - hostname=data['hostname'], - type=CONN_SSH, - login=data['login']) - new_ssh_host.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if 'host_tls_add' in request.POST: - form = ComputeAddTlsForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - new_tls_host = Compute(name=data['name'], - hostname=data['hostname'], - type=CONN_TLS, - login=data['login'], - password=data['password']) - new_tls_host.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if 'host_socket_add' in request.POST: - form = ComputeAddSocketForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - new_socket_host = Compute(name=data['name'], - details=data['details'], - hostname='localhost', - type=CONN_SOCKET, - login='', - password='') - new_socket_host.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if 'host_edit' in request.POST: - form = ComputeEditHostForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - compute_edit = Compute.objects.get(id=data['host_id']) - compute_edit.name = data['name'] - compute_edit.hostname = data['hostname'] - compute_edit.login = data['login'] - compute_edit.password = data['password'] - compute.edit_details = data['details'] - compute_edit.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - return render(request, 'computes.html', locals()) + return render(request, "computes/list.html", {"computes": computes}) -@login_required +@superuser_only def overview(request, compute_id): - """ - :param request: - :return: - """ + compute = get_object_or_404(Compute, pk=compute_id) + status = ( + "true" if connection_manager.host_is_up(compute.type, compute.hostname) is True else "false" + ) - if not request.user.is_superuser: - return HttpResponseRedirect(reverse('index')) + conn = wvmHostDetails( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + hostname, host_arch, host_memory, logical_cpu, model_cpu, uri_conn = conn.get_node_info() + hypervisor = conn.get_hypervisors_domain_types() + mem_usage = conn.get_memory_usage() + emulator = conn.get_emulator(host_arch) + version = conn.get_version() + lib_version = conn.get_lib_version() + conn.close() - error_messages = [] + return render(request, "overview.html", locals()) + + +@superuser_only +def instances(request, compute_id): compute = get_object_or_404(Compute, pk=compute_id) - try: - conn = wvmHostDetails(compute.hostname, - compute.login, - compute.password, - compute.type) - hostname, host_arch, host_memory, logical_cpu, model_cpu, uri_conn = conn.get_node_info() - hypervisor = conn.hypervisor_type() - mem_usage = conn.get_memory_usage() - conn.close() - except libvirtError as lib_err: - error_messages.append(lib_err) + utils.refresh_instance_database(compute) + instances = Instance.objects.filter(compute=compute).prefetch_related("userinstance_set") - return render(request, 'overview.html', locals()) + return render(request, "computes/instances.html", {"compute": compute, "instances": instances}) + + +@superuser_only +def compute_create(request, FormClass): + form = FormClass(request.POST or None) + if form.is_valid(): + form.save() + return redirect(reverse("computes")) + + return render(request, "computes/form.html", {"form": form}) + + +@superuser_only +def compute_update(request, compute_id): + compute = get_object_or_404(Compute, pk=compute_id) + + if compute.type == 1: + FormClass = TcpComputeForm + elif compute.type == 2: + FormClass = SshComputeForm + elif compute.type == 3: + FormClass = TlsComputeForm + elif compute.type == 4: + FormClass = SocketComputeForm + + form = FormClass(request.POST or None, instance=compute) + if form.is_valid(): + form.save() + return redirect(reverse("computes")) + + return render(request, "computes/form.html", {"form": form}) + + +@superuser_only +def compute_delete(request, compute_id): + compute = get_object_or_404(Compute, pk=compute_id) + if request.method == "POST": + compute.delete() + return redirect("computes") + + return render( + request, + "common/confirm_delete.html", + {"object": compute}, + ) -@login_required def compute_graph(request, compute_id): """ :param request: + :param compute_id: :return: """ - - points = 5 - datasets = {} - cookies = {} compute = get_object_or_404(Compute, pk=compute_id) - curent_time = time.strftime("%H:%M:%S") - try: - conn = wvmHostDetails(compute.hostname, - compute.login, - compute.password, - compute.type) + conn = wvmHostDetails( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + current_time = timezone.now().strftime("%H:%M:%S") cpu_usage = conn.get_cpu_usage() mem_usage = conn.get_memory_usage() conn.close() except libvirtError: - cpu_usage = 0 - mem_usage = 0 + cpu_usage = {"usage": 0} + mem_usage = {"usage": 0} + current_time = 0 - try: - cookies['cpu'] = request.COOKIES['cpu'] - cookies['mem'] = request.COOKIES['mem'] - cookies['timer'] = request.COOKIES['timer'] - except KeyError: - cookies['cpu'] = None - cookies['mem'] = None - - if not cookies['cpu'] or not cookies['mem']: - datasets['cpu'] = [0] * points - datasets['mem'] = [0] * points - datasets['timer'] = [0] * points - else: - datasets['cpu'] = eval(cookies['cpu']) - datasets['mem'] = eval(cookies['mem']) - datasets['timer'] = eval(cookies['timer']) - - datasets['timer'].append(curent_time) - datasets['cpu'].append(int(cpu_usage['usage'])) - datasets['mem'].append(int(mem_usage['usage']) / 1048576) - - if len(datasets['timer']) > points: - datasets['timer'].pop(0) - if len(datasets['cpu']) > points: - datasets['cpu'].pop(0) - if len(datasets['mem']) > points: - datasets['mem'].pop(0) - - data = json.dumps({'cpudata': datasets['cpu'], 'memdata': datasets['mem'], 'timeline': datasets['timer']}) + data = json.dumps( + { + "cpudata": cpu_usage["usage"], + "memdata": mem_usage, + "timeline": current_time, + } + ) response = HttpResponse() - response['Content-Type'] = "text/javascript" - response.cookies['cpu'] = datasets['cpu'] - response.cookies['timer'] = datasets['timer'] - response.cookies['mem'] = datasets['mem'] + response["Content-Type"] = "text/javascript" response.write(data) return response + + +def get_compute_disk_buses(request, compute_id, arch, machine, disk): + """ + :param request: + :param compute_id: + :param arch: + :param machine: + :param disk: + :return: + """ + data = dict() + compute = get_object_or_404(Compute, pk=compute_id) + try: + conn = wvmConnect( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + + disk_device_types = conn.get_disk_device_types(arch, machine) + + if disk in disk_device_types: + if disk == "disk": + data["bus"] = sorted(disk_device_types) + elif disk == "cdrom": + data["bus"] = ["ide", "sata", "scsi"] + elif disk == "floppy": + data["bus"] = ["fdc"] + elif disk == "lun": + data["bus"] = ["scsi"] + except libvirtError: + pass + + return HttpResponse(json.dumps(data)) + + +def get_compute_machine_types(request, compute_id, arch): + """ + :param request: + :param compute_id: + :param arch: + :return: + """ + data = dict() + try: + compute = get_object_or_404(Compute, pk=compute_id) + conn = wvmConnect( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + data["machines"] = conn.get_machine_types(arch) + except libvirtError: + pass + + return HttpResponse(json.dumps(data)) + + +def get_compute_video_models(request, compute_id, arch, machine): + """ + :param request: + :param compute_id: + :param arch: + :param machine: + :return: + """ + data = dict() + try: + compute = get_object_or_404(Compute, pk=compute_id) + conn = wvmConnect( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + data["videos"] = conn.get_video_models(arch, machine) + except libvirtError: + pass + + return HttpResponse(json.dumps(data)) + + +def get_dom_capabilities(request, compute_id, arch, machine): + """ + :param request: + :param compute_id: + :param arch: + :param machine: + :return: + """ + data = dict() + try: + compute = get_object_or_404(Compute, pk=compute_id) + conn = wvmConnect( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + data["videos"] = conn.get_disk_device_types(arch, machine) + data["bus"] = conn.get_disk_device_types(arch, machine) + except libvirtError: + pass + + return HttpResponse(json.dumps(data)) diff --git a/conf/daemon/gstfsd b/conf/daemon/gstfsd index 2e68312..2129a99 100644 --- a/conf/daemon/gstfsd +++ b/conf/daemon/gstfsd @@ -3,24 +3,24 @@ # gstfsd - WebVirtCloud daemon for managing VM's filesystem # -import SocketServer +import socketserver import json import guestfs import re - PORT = 16510 ADDRESS = "0.0.0.0" -class MyTCPServer(SocketServer.ThreadingTCPServer): +class MyTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True -class MyTCPServerHandler(SocketServer.BaseRequestHandler): +class MyTCPServerHandler(socketserver.BaseRequestHandler): def handle(self): # recive data - data = json.loads(self.request.recv(1024).strip()) + d = self.request.recv(1024).strip() + data = json.loads(d) # GuestFS gfs = guestfs.GuestFS(python_return_dict=True) @@ -42,17 +42,18 @@ class MyTCPServerHandler(SocketServer.BaseRequestHandler): if data['action'] == 'publickey': if not gfs.is_dir('/root/.ssh'): gfs.mkdir('/root/.ssh') - gfs.chmod(0700, "/root/.ssh") + gfs.chmod(700, "/root/.ssh") gfs.write('/root/.ssh/authorized_keys', data['key']) - gfs.chmod(0600, '/root/.ssh/authorized_keys') + gfs.chmod(600, '/root/.ssh/authorized_keys') self.request.sendall(json.dumps({'return': 'success'})) gfs.umount(part) except RuntimeError: pass gfs.shutdown() gfs.close() - except RuntimeError, err: - self.request.sendall(json.dumps({'return': 'error', 'message': err.message})) + except Exception as err: + self.request.sendall(bytes(json.dumps({'return': 'error', 'message': str(err)}).encode())) + server = MyTCPServer((ADDRESS, PORT), MyTCPServerHandler) server.serve_forever() diff --git a/conf/nginx/centos_nginx.conf b/conf/nginx/centos_nginx.conf new file mode 100644 index 0000000..2327039 --- /dev/null +++ b/conf/nginx/centos_nginx.conf @@ -0,0 +1,37 @@ +# For more information on configuration, see: +# * Official English Documentation: http://nginx.org/en/docs/ +# * Official Russian Documentation: http://nginx.org/ru/docs/ + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /run/nginx.pid; + +# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. +include /usr/share/nginx/modules/*.conf; + +events { + worker_connections 1024; +} + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Load modular configuration files from the /etc/nginx/conf.d directory. + # See http://nginx.org/en/docs/ngx_core_module.html#include + # for more information. + include /etc/nginx/conf.d/*.conf; +} diff --git a/conf/nginx/debian_nginx.conf b/conf/nginx/debian_nginx.conf new file mode 100644 index 0000000..5b107cb --- /dev/null +++ b/conf/nginx/debian_nginx.conf @@ -0,0 +1,85 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + # server_tokens off; + + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # SSL Settings + ## + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE + ssl_prefer_server_ciphers on; + + ## + # Logging Settings + ## + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + ## + # Gzip Settings + ## + + gzip on; + + # gzip_vary on; + # gzip_proxied any; + # gzip_comp_level 6; + # gzip_buffers 16 8k; + # gzip_http_version 1.1; + # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + ## + # Virtual Host Configs + ## + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} + + +#mail { +# # See sample authentication script at: +# # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript +# +# # auth_http localhost/auth.php; +# # pop3_capabilities "TOP" "USER"; +# # imap_capabilities "IMAP5rev1" "UIDPLUS"; +# +# server { +# listen localhost:110; +# protocol pop3; +# proxy on; +# } +# +# server { +# listen localhost:143; +# protocol imap; +# proxy on; +# } +#} diff --git a/conf/nginx/ubuntu_nginx.conf b/conf/nginx/ubuntu_nginx.conf new file mode 100644 index 0000000..5b107cb --- /dev/null +++ b/conf/nginx/ubuntu_nginx.conf @@ -0,0 +1,85 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + # server_tokens off; + + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # SSL Settings + ## + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE + ssl_prefer_server_ciphers on; + + ## + # Logging Settings + ## + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + ## + # Gzip Settings + ## + + gzip on; + + # gzip_vary on; + # gzip_proxied any; + # gzip_comp_level 6; + # gzip_buffers 16 8k; + # gzip_http_version 1.1; + # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + ## + # Virtual Host Configs + ## + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} + + +#mail { +# # See sample authentication script at: +# # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript +# +# # auth_http localhost/auth.php; +# # pop3_capabilities "TOP" "USER"; +# # imap_capabilities "IMAP5rev1" "UIDPLUS"; +# +# server { +# listen localhost:110; +# protocol pop3; +# proxy on; +# } +# +# server { +# listen localhost:143; +# protocol imap; +# proxy on; +# } +#} diff --git a/conf/nginx/webvirtcloud.conf b/conf/nginx/webvirtcloud.conf index 70b8e0e..d16a814 100644 --- a/conf/nginx/webvirtcloud.conf +++ b/conf/nginx/webvirtcloud.conf @@ -15,9 +15,21 @@ server { proxy_set_header X-Forwarded-for $proxy_add_x_forwarded_for; proxy_set_header Host $host:$server_port; proxy_set_header X-Forwarded-Proto $remote_addr; - proxy_connect_timeout 600; - proxy_read_timeout 600; - proxy_send_timeout 600; + proxy_set_header X-Forwarded-Ssl off; + proxy_connect_timeout 1800; + proxy_read_timeout 1800; + proxy_send_timeout 1800; client_max_body_size 1024M; } + + location /novncd/ { + proxy_pass http://wsnovncd; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } } + +upstream wsnovncd { + server 127.0.0.1:6080; +} \ No newline at end of file diff --git a/conf/requirements.txt b/conf/requirements.txt index e01113b..bbc4cc8 100644 --- a/conf/requirements.txt +++ b/conf/requirements.txt @@ -1,6 +1,15 @@ -Django==1.8.11 -websockify==0.8.0 -gunicorn==19.3.0 -libvirt-python==1.3.2 -#http://github.com/retspen/retspen.github.io/raw/master/libxml2-python-2.9.1.tar.gz -http://git.gnome.org/browse/libxml2/snapshot/libxml2-2.9.1.tar.gz#egg=libxml2-python&subdirectory=python +Django==2.2.17 +django-bootstrap4==2.3.1 +django-icons==2.2.0 +django-login-required-middleware==0.5.0 +django-otp==1.0.2 +django-qr-code==1.3.1 +gunicorn==20.0.4 +importlib-metadata==2.0.0 +libsass==0.20.1 +libvirt-python==6.9.0 +lxml==4.6.1 +qrcode==6.1 +rwlock==0.0.7 +websockify==0.9.0 +zipp==3.4.0 diff --git a/conf/runit/novncd.sh b/conf/runit/novncd.sh index 99089d7..b071864 100755 --- a/conf/runit/novncd.sh +++ b/conf/runit/novncd.sh @@ -1,4 +1,18 @@ #!/bin/sh + # `/sbin/setuser www-data` runs the given command as the user `www-data`. -cd /srv/webvirtcloud -exec /sbin/setuser www-data /srv/webvirtcloud/venv/bin/python /srv/webvirtcloud/console/novncd >> /var/log/novncd.log 2>&1 +RUNAS=$(which setuser) +[ -z "$RUNAS" ] && RUNAS="$(which sudo) -u" +USER=www-data + +DJANGO_PROJECT=/srv/webvirtcloud +PYTHON=$DJANGO_PROJECT/venv/bin/python3 +NOVNCD=$DJANGO_PROJECT/console/novncd + +# make novncd debug, verbose +#PARAMS="-d -v" + +LOG=/var/log/novncd.log + +cd $DJANGO_PROJECT || exit +exec "$RUNAS" "$USER" "$PYTHON" "$NOVNCD" "$PARAMS" >> $LOG 2>&1 diff --git a/conf/runit/secret_generator.py b/conf/runit/secret_generator.py new file mode 100644 index 0000000..e22ca7b --- /dev/null +++ b/conf/runit/secret_generator.py @@ -0,0 +1,5 @@ +import random +import string + +haystack = string.ascii_letters + string.digits + string.punctuation +print(''.join([random.SystemRandom().choice(haystack.replace('/', '').replace('\'', '').replace('\"', '')) for _ in range(50)])) diff --git a/conf/runit/webvirtcloud.sh b/conf/runit/webvirtcloud.sh index 179e34f..4cb81b0 100755 --- a/conf/runit/webvirtcloud.sh +++ b/conf/runit/webvirtcloud.sh @@ -1,4 +1,4 @@ #!/bin/sh # `/sbin/setuser www-data` runs the given command as the user `www-data`. -cd /srv/webvirtcloud +cd /srv/webvirtcloud || exit exec /sbin/setuser www-data /srv/webvirtcloud/venv/bin/gunicorn webvirtcloud.wsgi:application -c /srv/webvirtcloud/gunicorn.conf.py >> /var/log/webvirtcloud.log 2>&1 diff --git a/conf/supervisor/gstfsd.conf b/conf/supervisor/gstfsd.conf index 2834b30..094f41c 100644 --- a/conf/supervisor/gstfsd.conf +++ b/conf/supervisor/gstfsd.conf @@ -1,5 +1,5 @@ [program:gstfsd] -command=/usr/bin/python /usr/local/bin/gstfsd +command=/srv/webvirtcloud/venv/bin/python3 /usr/local/bin/gstfsd directory=/usr/local/bin user=root autostart=true diff --git a/conf/supervisor/webvirtcloud.conf b/conf/supervisor/webvirtcloud.conf index 2994bc9..692fe8a 100644 --- a/conf/supervisor/webvirtcloud.conf +++ b/conf/supervisor/webvirtcloud.conf @@ -7,7 +7,7 @@ autorestart=true redirect_stderr=true [program:novncd] -command=/srv/webvirtcloud/venv/bin/python /srv/webvirtcloud/console/novncd +command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/novncd directory=/srv/webvirtcloud user=www-data autostart=true diff --git a/conf/test-vm.xml b/conf/test-vm.xml new file mode 100644 index 0000000..d14f25f --- /dev/null +++ b/conf/test-vm.xml @@ -0,0 +1,119 @@ + + test-vm + 1bd3c1f2-dd12-4b8d-a298-dff387cb9f93 + 131072 + 131072 + 1 + + hvm + + + + + + + + + + + destroy + restart + restart + + /usr/bin/qemu-system-x86_64 + + + + +
+ + + + + +
+ + +
+ + +
+ + + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + + +
+ + + + + + + + + + +
+ + +
+ + +
+ + + + + + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+ +{% endif %} diff --git a/instances/templates/add_instance_owner_block.html b/instances/templates/add_instance_owner_block.html index b7e6df9..8d9a2e3 100644 --- a/instances/templates/add_instance_owner_block.html +++ b/instances/templates/add_instance_owner_block.html @@ -1,7 +1,7 @@ {% load i18n %} {% if request.user.is_superuser %} - - + + @@ -9,27 +9,27 @@ diff --git a/instances/templates/add_instance_volume.html b/instances/templates/add_instance_volume.html new file mode 100644 index 0000000..1ef0c4b --- /dev/null +++ b/instances/templates/add_instance_volume.html @@ -0,0 +1,154 @@ +{% load i18n %} +{% if request.user.is_superuser %} + + + + + + +{% endif %} diff --git a/instances/templates/allinstances.html b/instances/templates/allinstances.html new file mode 100644 index 0000000..8c29794 --- /dev/null +++ b/instances/templates/allinstances.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% load i18n %} +{% load icons %} +{% load staticfiles %} +{% block title %}{% trans "Instances" %}{% endblock %} +{% block style %} + +{% endblock %} +{% block page_heading %}{% trans "Instances" %}{% endblock page_heading %} + +{% block page_heading_extra %} +{% if request.user.is_superuser %} + {% include 'create_inst_block.html' %} +{% endif %} + +{% endblock page_heading_extra %} + +{% block content %} + {% for compute in computes %} + {% if compute.status is not True %} + + {% endif %} + {% endfor %} +
+ {% if app_settings.VIEW_INSTANCES_LIST_STYLE == 'grouped' and request.user.is_superuser %} + {% include 'allinstances_index_grouped.html' %} + {% else %} + {% include 'allinstances_index_nongrouped.html' %} + {% endif %} +
+{% endblock content %} +{% block script %} + + + {% if request.user.is_superuser %} + + {% endif %} +{% endblock script %} diff --git a/instances/templates/allinstances_index_grouped.html b/instances/templates/allinstances_index_grouped.html new file mode 100644 index 0000000..f1d9440 --- /dev/null +++ b/instances/templates/allinstances_index_grouped.html @@ -0,0 +1,94 @@ +{% load i18n %} +{% load icons %} + + + + + + + + + + + + + + {% for compute in computes %} + {% if compute.status is True and compute.instance_set.count > 0 %} + + + + + + + + + + {% for instance in compute.instance_set.all %} + + + + + + + + + + {% endfor %} + {% endif %} + {% endfor %} + +
#{% trans "Name" %}
{% trans "Description" %}
{% trans "User"%}{% trans "Status" %}{% trans "VCPU" %}{% trans "Memory" %}{% trans "Actions" %} & {% trans "Mem Usage" %}
+ + + {{ compute.name }} + {{ compute.instance_set.count }} + + {% trans "Connected" %} + {{ compute.cpu_count }}{{ compute.ram_size|filesizeformat }} +
+
{{ compute.ram_usage }}% +
+
+
{{ forloop.counter }} + {{ instance.name }} +
+

{{ instance.title }}

+
+ + {% if instance.userinstance_set.all.count > 0 %} + {{ instance.userinstance_set.all.0.user }} + {% if instance.userinstance_set.all.count > 1 %} + (+{{ instance.userinstance_set.all.count|add:"-1" }}) + {% endif %} + {% endif %} + + + {% if instance.proxy.instance.info.0 == 1 %}{% trans "Active" %}{% endif %} + {% if instance.proxy.instance.info.0 == 5 %}{% trans "Off" %}{% endif %} + {% if instance.proxy.instance.info.0 == 3 %} + {% trans "Suspended" %} + {% endif %} + {{ instance.proxy.instance.info.3 }}{{ instance.cur_memory }} MB + {% include 'instance_actions.html' %} +
+ +{% block script %} + +{% endblock %} diff --git a/instances/templates/allinstances_index_nongrouped.html b/instances/templates/allinstances_index_nongrouped.html new file mode 100644 index 0000000..cde9d77 --- /dev/null +++ b/instances/templates/allinstances_index_nongrouped.html @@ -0,0 +1,56 @@ +{% load i18n %} + + + + + {% if request.user.is_superuser %} + + {% endif %} + + + + + + + + {% for instance in instances %} + {% if instance.compute.status is True %} + + + {% if request.user.is_superuser %} + + {% endif %} + + + + + + {% endif %} + {% endfor %} + +
{% trans 'Name' %}
{% trans 'Description' %}
{% trans 'Host' %}
{% trans 'User' %}
{% trans 'Status' %}{% trans 'VCPU' %}{% trans 'Memory' %}{% trans 'Actions' %}
+ + {{ instance.name }} + +
+

{{ instance.title }}

+
+ {{ instance.compute.name }}
+ + {% if instance.userinstance_set.all.count > 0 %} + {{ instance.userinstance_set.all.0.user }} + {% if instance.userinstance_set.all.count > 1 %} + (+{{ instance.userinstance_set.all.count|add:"-1" }}) + {% endif %} + {% endif %} + +
+ {% if instance.proxy.instance.info.0 == 1 %}{% trans "Active" %}{% endif %} + {% if instance.proxy.instance.info.0 == 5 %}{% trans "Off" %}{% endif %} + {% if instance.proxy.instance.info.0 == 3 %}{% trans "Suspended" %}{% endif %} + {{ instance.proxy.instance.info.3 }}{{ instance.cur_memory }} MB + {% include 'instance_actions.html' %} +
diff --git a/instances/templates/bottom_bar.html b/instances/templates/bottom_bar.html new file mode 100644 index 0000000..5f889e7 --- /dev/null +++ b/instances/templates/bottom_bar.html @@ -0,0 +1,25 @@ +{% load i18n %} + diff --git a/instances/templates/create_flav_block.html b/instances/templates/create_flav_block.html new file mode 100644 index 0000000..cd10d30 --- /dev/null +++ b/instances/templates/create_flav_block.html @@ -0,0 +1,34 @@ +{% load i18n %} +{% load bootstrap4 %} +{% if request.user.is_superuser %} + + + + +{% endif %} \ No newline at end of file diff --git a/instances/templates/create_inst_block.html b/instances/templates/create_inst_block.html index 264aa75..f3549f2 100644 --- a/instances/templates/create_inst_block.html +++ b/instances/templates/create_inst_block.html @@ -1,50 +1,48 @@ {% load i18n %} -{% if request.user.is_superuser %} - - - + + + - -