webappusers in new servers

This commit is contained in:
jorgepastorr 2023-07-24 17:39:18 +02:00
parent 476a8591c0
commit afabe560a3
13 changed files with 349 additions and 3 deletions

View File

@ -0,0 +1,48 @@
# Generated by Django 2.2.28 on 2023-07-22 08:01
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration):
initial = True
dependencies = [
('orchestration', '__first__'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SystemUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('directory', models.CharField(blank=True, help_text="Optional directory relative to user's home.", max_length=256, verbose_name='directory')),
('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='systemusers', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('groups', models.ManyToManyField(blank=True, help_text='A new group will be created for the user. Which additional groups would you like them to be a member of?', to='systemusers.SystemUser')),
],
),
migrations.CreateModel(
name='WebappUsers',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('target_server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Server')),
],
options={
'unique_together': {('username', 'target_server')},
},
),
]

View File

@ -0,0 +1,3 @@
[Trash Info]
Path=orchestra/contrib/systemusers/migrations/0001_initial.py
DeletionDate=2023-07-22T10:04:42

View File

@ -9,8 +9,8 @@ from orchestra.contrib.accounts.filters import IsActiveListFilter
from .actions import set_permission, create_link from .actions import set_permission, create_link
from .filters import IsMainListFilter from .filters import IsMainListFilter
from .forms import SystemUserCreationForm, SystemUserChangeForm from .forms import SystemUserCreationForm, SystemUserChangeForm, WebappUserChangeForm, WebappUserCreationForm
from .models import SystemUser from .models import SystemUser, WebappUsers
class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin): class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin):
@ -78,4 +78,34 @@ class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, Extende
return super(SystemUserAdmin, self).has_delete_permission(request, obj) return super(SystemUserAdmin, self).has_delete_permission(request, obj)
class WebappUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin):
list_display = (
'username', 'account_link', 'shell', 'home', 'target_server'
)
fieldsets = (
(None, {
'fields': ('account_link', 'username', 'password', )
}),
(_("System"), {
'fields': ('shell', 'home', 'target_server'),
}),
)
add_fieldsets = (
(None, {
'fields': ('account_link', 'username', 'password1', 'password2')
}),
(_("System"), {
'fields': ('shell', 'home', 'target_server'),
}),
)
search_fields = ('username', 'account__username')
readonly_fields = ('account_link',)
change_readonly_fields = ('username', 'home', 'target_server')
add_form = WebappUserCreationForm
form = WebappUserChangeForm
ordering = ('-id',)
admin.site.register(SystemUser, SystemUserAdmin) admin.site.register(SystemUser, SystemUserAdmin)
admin.site.register(WebappUsers, WebappUserAdmin)

View File

@ -2,6 +2,7 @@ import sys
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_migrate from django.db.models.signals import post_migrate
from django.utils.translation import gettext_lazy as _
from orchestra.core import services from orchestra.core import services
@ -11,11 +12,12 @@ class SystemUsersConfig(AppConfig):
verbose_name = "System users" verbose_name = "System users"
def ready(self): def ready(self):
from .models import SystemUser from .models import SystemUser, WebappUsers
services.register(SystemUser, icon='roleplaying.png') services.register(SystemUser, icon='roleplaying.png')
if 'migrate' in sys.argv and 'accounts' not in sys.argv: if 'migrate' in sys.argv and 'accounts' not in sys.argv:
post_migrate.connect(self.create_initial_systemuser, post_migrate.connect(self.create_initial_systemuser,
dispatch_uid="orchestra.contrib.systemusers.apps.create_initial_systemuser") dispatch_uid="orchestra.contrib.systemusers.apps.create_initial_systemuser")
services.register(WebappUsers, icon='roleplaying.png', verbose_name =_('WebApp User'), verbose_name_plural=_("Webapp users"))
def create_initial_systemuser(self, **kwargs): def create_initial_systemuser(self, **kwargs):
from .models import SystemUser from .models import SystemUser

View File

@ -719,3 +719,114 @@ class UNIXUserControllerNewServers(ServiceController):
} }
context['deleted_home'] = settings.SYSTEMUSERS_MOVE_ON_DELETE_PATH % context context['deleted_home'] = settings.SYSTEMUSERS_MOVE_ON_DELETE_PATH % context
return replace(context, "'", '"') return replace(context, "'", '"')
class WebappUserController(ServiceController):
"""
Basic UNIX system user/group support based on <tt>useradd</tt>, <tt>usermod</tt>, <tt>userdel</tt> and <tt>groupdel</tt>.
Autodetects and uses ACL if available, for better permission management.
"""
verbose_name = _("SFTP Webapp user")
model = 'systemusers.WebappUsers'
actions = ('save', 'delete',)
doc_settings = (settings, (
'SYSTEMUSERS_DEFAULT_GROUP_MEMBERS',
'SYSTEMUSERS_MOVE_ON_DELETE_PATH',
'SYSTEMUSERS_FORBIDDEN_PATHS'
))
def save(self, user):
context = self.get_context(user)
if not context['user']:
return
self.append(textwrap.dedent("""
# Update/create user state for %(user)s
if id %(user)s &> /dev/null; then
usermod %(user)s --home '/%(home)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s'
else
useradd_code=0
useradd %(user)s --home '/%(home)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s' || useradd_code=$?
if [[ $useradd_code -eq 8 ]]; then
# User is logged in, kill and retry
pkill -u %(user)s; sleep 2
pkill -9 -u %(user)s; sleep 1
useradd %(user)s --home '/%(home)s' \\
--password '%(password)s' \\
--shell '%(shell)s' \\
--groups '%(groups)s'
elif [[ $useradd_code -ne 0 ]]; then
exit $useradd_code
fi
fi
usermod -aG %(user)s %(parent)s
# Ensure homedir exists and has correct perms
mkdir -p '%(webapp_path)s' || exit_code=1
chown %(user)s:%(user)s %(webapp_path)s || exit_code=1
chmod 750 '%(webapp_path)s' || exit_code=1
# Create /chroots/$uid symlink into /home/$user.parent/webapps/
uid=$(id -u "%(user)s")
ln -n -f -s %(base_home)s/webapps /chroots/$uid || exit_code=1
""") % context
)
def delete(self, user):
context = self.get_context(user)
if not context['user']:
return
self.append(textwrap.dedent("""\
# Delete %(user)s user
uid=$(id -u "%(user)s")
nohup bash -c 'sleep 2 && killall -u %(user)s -s KILL' &> /dev/null &
killall -u %(user)s || true
userdel %(user)s || exit_code=$?
groupdel %(group)s || exit_code=$?
# Delete /chroots/$uid symlink into /home/$user.parent/webapps/
rm /chroots/$uid
""") % context
)
if context['deleted_home']:
self.append(textwrap.dedent("""\
# Move home into SYSTEMUSERS_MOVE_ON_DELETE_PATH, nesting if exists.
mv '%(webapp_path)s' '%(deleted_home)s' || exit_code=$?
""") % context
)
else:
self.append("rm -fr -- '%(webapp_path)s'" % context)
def get_groups(self, user):
groups = []
groups = list(user.account.systemusers.exclude(username=user.username).values_list('username', flat=True))
groups.append("webapp-systemusers")
return groups
def get_context(self, user):
context = {
'object_id': user.pk,
'user': user.username,
'group': user.username,
'groups': ','.join(self.get_groups(user)),
'password': user.password, #if user.active else '*%s' % user.password,
'shell': user.shell,
'home': user.home,
'base_home': user.get_base_home(),
'webapp_path': os.path.normpath(user.get_base_home() + "/webapps/" + user.home),
'parent': user.get_parent(),
}
context['deleted_home'] = context['webapp_path'] + ".delete"
return replace(context, "'", '"')

View File

@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from orchestra.forms import UserCreationForm, UserChangeForm from orchestra.forms import UserCreationForm, UserChangeForm
from orchestra.contrib.webapps.settings import WEBAPP_NEW_SERVERS
from . import settings from . import settings
from .models import SystemUser from .models import SystemUser
@ -162,3 +163,27 @@ class PermissionForm(LinkForm):
('r', _("Read only")), ('r', _("Read only")),
('w', _("Write only")) ('w', _("Write only"))
)) ))
# ----------------------------
class WebappUserFormMixin(object):
def __init__(self, *args, **kwargs):
super(WebappUserFormMixin, self).__init__(*args, **kwargs)
def clean(self):
if not self.instance.pk:
server = self.cleaned_data.get('target_server')
if server:
if server.name not in WEBAPP_NEW_SERVERS:
self.add_error("target_server", _(f"{server} does not belong to the new servers"))
return self.cleaned_data
class WebappUserCreationForm(WebappUserFormMixin, UserCreationForm):
pass
class WebappUserChangeForm(WebappUserFormMixin, UserChangeForm):
pass

View File

@ -0,0 +1,32 @@
# Generated by Django 2.2.28 on 2023-07-22 08:04
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SystemUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('directory', models.CharField(blank=True, help_text="Optional directory relative to user's home.", max_length=256, verbose_name='directory')),
('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='systemusers', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('groups', models.ManyToManyField(blank=True, help_text='A new group will be created for the user. Which additional groups would you like them to be a member of?', to='systemusers.SystemUser')),
],
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 2.2.28 on 2023-07-22 08:05
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('orchestration', '__first__'),
('systemusers', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='WebappUsers',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')),
('password', models.CharField(max_length=128, verbose_name='password')),
('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')),
('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('target_server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Server')),
],
options={
'unique_together': {('username', 'target_server')},
},
),
]

View File

@ -136,3 +136,43 @@ class SystemUser(models.Model):
def get_home(self): def get_home(self):
return os.path.normpath(os.path.join(self.home, self.directory)) return os.path.normpath(os.path.join(self.home, self.directory))
# ------------------
class WebappUsers(models.Model):
"""
System users for webapp
Username max_length determined by LINUX system user/group lentgh: 32
"""
username = models.CharField(_("username"), max_length=32, unique=True,
help_text=_("Required. 32 characters or fewer. Letters, digits and ./-/_ only."),
validators=[validators.validate_username])
password = models.CharField(_("password"), max_length=128)
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='accounts', on_delete=models.CASCADE)
home = models.CharField(_("WebappDir"), max_length=256, blank=True,
help_text=_("name dir webapp /home/&lt;main&gt;/webapps/&lt;DirName&gt;"),
validators=[validators.validate_string_dir])
shell = models.CharField(_("shell"), max_length=32, choices=settings.WEBAPPUSERS_SHELLS,
default='/dev/null')
target_server = models.ForeignKey('orchestration.Server', on_delete=models.CASCADE,
verbose_name=_("Server"))
class Meta:
unique_together = ('username', 'target_server')
verbose_name = 'WebAppUser'
verbose_name_plural = 'WebappUsers'
def __str__(self):
return self.username
def set_password(self, raw_password):
self.password = make_password(raw_password)
def get_base_home(self):
return os.path.normpath(self.account.main_systemuser.home)
def get_parent(self):
return self.account.main_systemuser

View File

@ -7,6 +7,13 @@ _names = ('user', 'username')
_backend_names = _names + ('group', 'shell', 'mainuser', 'home', 'base_home') _backend_names = _names + ('group', 'shell', 'mainuser', 'home', 'base_home')
WEBAPPUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS',
(
('/dev/null', _("No shell, SFTP only")),
('/bin/bash', "/bin/bash"),
),
)
SYSTEMUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS', SYSTEMUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS',
( (
('/dev/null', _("No shell, FTP only")), ('/dev/null', _("No shell, FTP only")),

View File

@ -282,3 +282,11 @@ WEBAPPS_CMS_CACHE_DIR = Setting('WEBAPPS_CMS_CACHE_DIR',
'/tmp/orchestra_cms_cache', '/tmp/orchestra_cms_cache',
help_text="Server-side cache directori for CMS tarballs.", help_text="Server-side cache directori for CMS tarballs.",
) )
WEBAPP_NEW_SERVERS = Setting('WEBAPP_NEW_SERVERS',
(
'bookworm',
'web-11.pangea.lan',
'web-12.pangea.lan',
)
)

View File

@ -177,3 +177,10 @@ def validate_phone(value, country):
raise ValidationError(msg) raise ValidationError(msg)
if not phonenumbers.is_valid_number(number): if not phonenumbers.is_valid_number(number):
raise ValidationError(msg) raise ValidationError(msg)
def validate_string_dir(value):
"""
A single non-empty line of free-form text with no whitespace.
"""
validators.RegexValidator('^[\_\-0-9a-z]+$',
_("Enter a valid name dir (spaceless lowercase text, number and _- )"), 'invalid')(value)