diff --git a/services/webapp/code/rosetta/core_app/computing_managers.py b/services/webapp/code/rosetta/core_app/computing_managers.py index f065b319329d016474e0d0ff75915ac22019b6c2..4889817483fe89d874ae35c0c683a5bc132a4d71 100644 --- a/services/webapp/code/rosetta/core_app/computing_managers.py +++ b/services/webapp/code/rosetta/core_app/computing_managers.py @@ -1,5 +1,5 @@ from .models import TaskStatuses, KeyPair, Task, Storage -from .utils import os_shell, get_ssh_access_mode_credentials +from .utils import os_shell, get_ssh_access_mode_credentials, sanitize_container_env_vars from .exceptions import ErrorMessage, ConsistencyException from django.conf import settings @@ -107,6 +107,15 @@ class InternalStandaloneComputingManager(StandaloneComputingManager): if not task.requires_proxy and task.password: run_command += ' -eAUTH_PASS={} '.format(task.password) + # Env vars if any + if task.container.env_vars: + + # Sanitize again just in case the DB got somehow compromised: + env_vars = sanitize_container_env_vars(task.container.env_vars) + + for env_var in env_vars: + run_command += ' -e{}={} '.format(env_var, env_vars[env_var]) + # User data volume #run_command += ' -v {}/user-{}:/data'.format(settings.LOCAL_USER_DATA_DIR, task.user.id) @@ -204,8 +213,19 @@ class SSHStandaloneComputingManager(StandaloneComputingManager, SSHComputingMana # Set pass if any authstring = '' if not task.requires_proxy_auth and task.password: - authstring = ' export SINGULARITYENV_AUTH_PASS={} && '.format(task.password) + authstring = ' && export SINGULARITYENV_AUTH_PASS={} '.format(task.password) + + # Env vars if any + if task.container.env_vars: + varsstring = '' + # Sanitize again just in case the DB got somehow compromised: + env_vars = sanitize_container_env_vars(task.container.env_vars) + for env_var in env_vars: + varsstring += ' && export SINGULARITYENV_{}={} '.format(env_var, env_vars[env_var]) + else: + varsstring = '' + # Handle storages (binds) binds = '' storages = Storage.objects.filter(computing=self.computing) @@ -241,7 +261,7 @@ class SSHStandaloneComputingManager(StandaloneComputingManager, SSHComputingMana run_command = 'ssh -o LogLevel=ERROR -i {} -4 -o StrictHostKeyChecking=no {}@{} '.format(computing_keys.private_key_file, computing_user, computing_host) run_command += '/bin/bash -c \'"rm -rf /tmp/{}_data && mkdir -p /tmp/{}_data/tmp && mkdir -p /tmp/{}_data/home && chmod 700 /tmp/{}_data && '.format(task.uuid, task.uuid, task.uuid, task.uuid) run_command += 'wget {}/api/v1/base/agent/?task_uuid={} -O /tmp/{}_data/agent.py &> /dev/null && export BASE_PORT=\$(python /tmp/{}_data/agent.py 2> /tmp/{}_data/task.log) && '.format(webapp_conn_string, task.uuid, task.uuid, task.uuid, task.uuid) - run_command += 'export SINGULARITY_NOHTTPS=true && export SINGULARITYENV_BASE_PORT=\$BASE_PORT && {} '.format(authstring) + run_command += 'export SINGULARITY_NOHTTPS=true && export SINGULARITYENV_BASE_PORT=\$BASE_PORT {} {} &&'.format(authstring, varsstring) run_command += 'exec nohup singularity run {} --pid --writable-tmpfs --no-home --home=/home/metauser --workdir /tmp/{}_data/tmp -B/tmp/{}_data/home:/home --containall --cleanenv '.format(binds, task.uuid, task.uuid) # Container part @@ -254,7 +274,18 @@ class SSHStandaloneComputingManager(StandaloneComputingManager, SSHComputingMana authstring = '' if not task.requires_proxy_auth and task.password: authstring = ' -e AUTH_PASS={} '.format(task.password) + + # Env vars if any + if task.container.env_vars: + varsstring = '' + # Sanitize again just in case the DB got somehow compromised: + env_vars = sanitize_container_env_vars(task.container.env_vars) + for env_var in env_vars: + varsstring += ' -e {}={} '.format(env_var, env_vars[env_var]) + else: + varsstring = '' + # Handle storages (binds) binds = '' storages = Storage.objects.filter(computing=self.computing) @@ -293,7 +324,7 @@ class SSHStandaloneComputingManager(StandaloneComputingManager, SSHComputingMana run_command = 'ssh -o LogLevel=ERROR -i {} -4 -o StrictHostKeyChecking=no {}@{} '.format(computing_keys.private_key_file, computing_user, computing_host) run_command += '/bin/bash -c \'"rm -rf /tmp/{}_data && mkdir /tmp/{}_data && chmod 700 /tmp/{}_data && '.format(task.uuid, task.uuid, task.uuid) run_command += 'wget {}/api/v1/base/agent/?task_uuid={} -O /tmp/{}_data/agent.py &> /dev/null && export TASK_PORT=\$(python /tmp/{}_data/agent.py 2> /tmp/{}_data/task.log) && '.format(webapp_conn_string, task.uuid, task.uuid, task.uuid, task.uuid) - run_command += '{} {} run -p \$TASK_PORT:{} {} {} '.format(prefix, container_engine, task.container.interface_port, authstring, binds) + run_command += '{} {} run -p \$TASK_PORT:{} {} {} {} '.format(prefix, container_engine, task.container.interface_port, authstring, varsstring, binds) if container_engine == 'podman': run_command += '--network=private --uts=private ' #run_command += '-d -t {}/{}:{}'.format(task.container.registry, task.container.image_name, task.container.image_tag) @@ -435,8 +466,19 @@ class SlurmSSHClusterComputingManager(ClusterComputingManager, SSHComputingManag # Set pass if any authstring = '' if not task.requires_proxy_auth and task.password: - authstring = ' export SINGULARITYENV_AUTH_PASS={} && '.format(task.password) + authstring = ' && export SINGULARITYENV_AUTH_PASS={} '.format(task.password) + + # Env vars if any + if task.container.env_vars: + varsstring = '' + # Sanitize again just in case the DB got somehow compromised: + env_vars = sanitize_container_env_vars(task.container.env_vars) + for env_var in env_vars: + varsstring += ' && export SINGULARITYENV_{}={} '.format(env_var, env_vars[env_var]) + else: + varsstring = '' + # Handle storages (binds) binds = '' storages = Storage.objects.filter(computing=self.computing) @@ -471,7 +513,7 @@ class SlurmSSHClusterComputingManager(ClusterComputingManager, SSHComputingManag run_command = 'ssh -o LogLevel=ERROR -i {} -4 -o StrictHostKeyChecking=no {}@{} '.format(computing_keys.private_key_file, computing_user, computing_host) run_command += '\'bash -c "echo \\"#!/bin/bash\nwget {}/api/v1/base/agent/?task_uuid={} -O \$HOME/agent_{}.py &> \$HOME/{}.log && export BASE_PORT=\\\\\\$(python \$HOME/agent_{}.py 2> \$HOME/{}.log) && '.format(webapp_conn_string, task.uuid, task.uuid, task.uuid, task.uuid, task.uuid) - run_command += 'export SINGULARITY_NOHTTPS=true && export SINGULARITYENV_BASE_PORT=\\\\\\$BASE_PORT && {} '.format(authstring) + run_command += 'export SINGULARITY_NOHTTPS=true && export SINGULARITYENV_BASE_PORT=\\\\\\$BASE_PORT {} {} && '.format(authstring, varsstring) run_command += 'rm -rf /tmp/{}_data && mkdir -p /tmp/{}_data/tmp &>> \$HOME/{}.log && mkdir -p /tmp/{}_data/home &>> \$HOME/{}.log && chmod 700 /tmp/{}_data && '.format(task.uuid, task.uuid, task.uuid, task.uuid, task.uuid, task.uuid) run_command += 'exec nohup singularity run {} --pid --writable-tmpfs --no-home --home=/home/metauser --workdir /tmp/{}_data/tmp -B/tmp/{}_data/home:/home --containall --cleanenv '.format(binds, task.uuid, task.uuid) diff --git a/services/webapp/code/rosetta/core_app/migrations/0031_container_env_vars.py b/services/webapp/code/rosetta/core_app/migrations/0031_container_env_vars.py new file mode 100644 index 0000000000000000000000000000000000000000..813af154081907a15393d903555ca126ede76b39 --- /dev/null +++ b/services/webapp/code/rosetta/core_app/migrations/0031_container_env_vars.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.1 on 2022-01-16 18:40 + +from django.conf import settings +from django.db import migrations + +# Load database-dependent JSON field +if 'sqlite' in settings.DATABASES['default']['ENGINE']: + from rosetta.core_app.fields import JSONField +else: + from django.contrib.postgres.fields import JSONField + + +class Migration(migrations.Migration): + + dependencies = [ + ('core_app', '0030_auto_20211218_2355'), + ] + + operations = [ + migrations.AddField( + model_name='container', + name='env_vars', + field=JSONField(blank=True, null=True, verbose_name='Container env vars'), + ), + ] diff --git a/services/webapp/code/rosetta/core_app/models.py b/services/webapp/code/rosetta/core_app/models.py index 5a886ac232448f72c87039f8a6c09ae3f5f76958..291e663c9b24e4260aec518559da700fed8d4d7b 100644 --- a/services/webapp/code/rosetta/core_app/models.py +++ b/services/webapp/code/rosetta/core_app/models.py @@ -141,6 +141,9 @@ class Container(models.Model): supports_interface_auth = models.BooleanField('Supports interface auth', default=False) # AUTH_USER / AUTH_PASS interface_auth_user = models.CharField('Interface auth fixed user if any', max_length=36, blank=True, null=True) + # Env vars for some container control + env_vars = JSONField('Container env vars', blank=True, null=True) + class Meta: ordering = ['name'] diff --git a/services/webapp/code/rosetta/core_app/templates/add_software.html b/services/webapp/code/rosetta/core_app/templates/add_software.html index 9487623e74a2b1b1f77e143603e2000732c25770..a18dd0675d39a253e267fbc66926d7eb337f3fad 100644 --- a/services/webapp/code/rosetta/core_app/templates/add_software.html +++ b/services/webapp/code/rosetta/core_app/templates/add_software.html @@ -135,6 +135,13 @@ </td> </tr> + <tr> + <td><b>Environment variables</b></td> + <td> + <textarea name="container_env_vars" rows="2" cols="22" placeholder='JSON format: {"VAR"="VALUE"}'></textarea> + </td> + </tr> + </table> </div> diff --git a/services/webapp/code/rosetta/core_app/templates/components/container.html b/services/webapp/code/rosetta/core_app/templates/components/container.html index 49a60652d435e4763dde73d6ce08664012acb593..ec99ff5f57819b7b5ecdca5ac0a0183d77a0b909 100644 --- a/services/webapp/code/rosetta/core_app/templates/components/container.html +++ b/services/webapp/code/rosetta/core_app/templates/components/container.html @@ -104,6 +104,12 @@ {% endif %} </td> </tr> + {% if container.env_vars %} + <tr> + <td><b>Env vars</b></td> + <td><pre>{{container.env_vars}}</pre></td> + </tr> + {% endif %} {% if container.user %} <tr> diff --git a/services/webapp/code/rosetta/core_app/tests/test_utils.py b/services/webapp/code/rosetta/core_app/tests/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..f48fbdee8a3a1572695acc9645e48659487ae391 --- /dev/null +++ b/services/webapp/code/rosetta/core_app/tests/test_utils.py @@ -0,0 +1,34 @@ +import json + +from django.contrib.auth.models import User + +from .common import BaseAPITestCase +from ..utils import sanitize_container_env_vars + +class TestUtils(BaseAPITestCase): + + def setUp(self): + pass + + def test_sanitize_user_env_vars(self): + '''Test sanitize use env vars''' + + # Basic + env_vars = {'myvar': 'a'} + self.assertEqual(sanitize_container_env_vars(env_vars),env_vars) + + # Allowed specia + env_vars = {'myvar': '/a_directory/a-test'} + self.assertEqual(sanitize_container_env_vars(env_vars),env_vars) + + # Potential malicious + env_vars = {'myvar': '$(rm -rf)'} + with self.assertRaises(ValueError): + sanitize_container_env_vars(env_vars) + + + + + + + diff --git a/services/webapp/code/rosetta/core_app/utils.py b/services/webapp/code/rosetta/core_app/utils.py index 1818e42164b174a4af42c0c4504c1b2e5da87790..b48a29d73724a7a10efb6afbc1b654efec9c3414 100644 --- a/services/webapp/code/rosetta/core_app/utils.py +++ b/services/webapp/code/rosetta/core_app/utils.py @@ -1,4 +1,5 @@ import os +import re import hashlib import traceback import hashlib @@ -732,8 +733,13 @@ def get_ssh_access_mode_credentials(computing, user): +def sanitize_container_env_vars(env_vars): + + for env_var in env_vars: + + # Check only alphanumeric chars, slashed, dashes and underscores + if not re.match("^[/A-Za-z0-9_-]*$", env_vars[env_var]): + raise ValueError('Value "{}" for env var "{}" is not valid: only alphanumeric, slashes, dashes and underscores are.'.format(env_vars[env_var], env_var)) - - - + return env_vars diff --git a/services/webapp/code/rosetta/core_app/views.py b/services/webapp/code/rosetta/core_app/views.py index 4605b3cdde4b554cb63c4fa8a62e706ffec30fbf..3961f733a3e6b8c63fe1d0996cd3945bf20887b3 100644 --- a/services/webapp/code/rosetta/core_app/views.py +++ b/services/webapp/code/rosetta/core_app/views.py @@ -11,7 +11,7 @@ from django.contrib.auth.models import User from django.shortcuts import redirect from django.db.models import Q from .models import Profile, LoginToken, Task, TaskStatuses, Container, Computing, KeyPair, Page -from .utils import send_email, format_exception, timezonize, os_shell, booleanize, debug_param, get_task_tunnel_host, get_task_proxy_host, random_username, setup_tunnel_and_proxy, finalize_user_creation, get_md5 +from .utils import send_email, format_exception, timezonize, os_shell, booleanize, get_task_tunnel_host, get_task_proxy_host, random_username, setup_tunnel_and_proxy, finalize_user_creation, sanitize_container_env_vars from .decorators import public_view, private_view from .exceptions import ErrorMessage @@ -951,6 +951,11 @@ def add_software(request): else: container_supports_pass_auth = False + # Environment variables + container_env_vars = request.POST.get('container_env_vars', None) + if container_env_vars: + container_env_vars = sanitize_container_env_vars(json.loads(container_env_vars)) + # Log #logger.debug('Creating new container object with image="{}", type="{}", registry="{}", ports="{}"'.format(container_image, container_type, container_registry, container_ports)) @@ -968,7 +973,8 @@ def add_software(request): interface_protocol = container_interface_protocol, interface_transport = container_interface_transport, supports_custom_interface_port = container_supports_custom_interface_port, - supports_interface_auth = container_supports_pass_auth) + supports_interface_auth = container_supports_pass_auth, + env_vars = container_env_vars) # Set added switch data['added'] = True