diff --git a/actions/samba b/actions/samba index 034584671..3cadf7444 100755 --- a/actions/samba +++ b/actions/samba @@ -20,14 +20,16 @@ Configuration helper for samba. """ import argparse +import configparser +import json import os import shutil import subprocess import augeas from plinth import action_utils +from plinth.modules.samba.manifest import SHARES_CONF_BACKUP_FILE, SHARES_PATH -SHARES_PATH = '/var/lib/samba/shares' DEFAULT_FILE = '/etc/default/samba' CONF_PATH = '/etc/samba/smb-freedombox.conf' @@ -35,9 +37,10 @@ CONF = r''' # # This file is managed and overwritten by Plinth. If you wish to manage # Samba yourself, disable Samba in Plinth, remove this file and remove -# the --configfile parameter in /etc/default/samba +# line with --configfile parameter in /etc/default/samba. # -# To view configured samba shares use command `net conf list` +# Configuration parameters which differ from Debian default configuration +# are commented. To view configured samba shares use command `net conf list`. # [global] @@ -53,6 +56,8 @@ CONF = r''' passwd chat = *Enter\snew\s*\spassword:* %n\n *Retype\snew\s*\spassword:* %n\n *password\supdated\ssuccessfully* . pam password change = yes map to guest = bad user + # connection inactivity timeout in minutes + deadtime = 5 # enable registry based shares registry shares = yes ''' # noqa: E501 @@ -65,27 +70,85 @@ def parse_arguments(): subparsers.add_parser('setup', help='Configure samba after install') + subparsers.add_parser('get-shares', help='Get configured samba shares') + + subparser = subparsers.add_parser('add-share', help='Add new samba share') + subparser.add_argument('--mount-point', help='Path of the mount point', + required=True) + subparser.add_argument('--windows-filesystem', required=False, + default=False, action='store_true', + help='Path is Windows filesystem') + + subparser = subparsers.add_parser( + 'delete-share', help='Delete a samba share configuration') + subparser.add_argument('--mount-point', help='Path of the mount point', + required=True) + + subparsers.add_parser('dump-shares', + help='Dump share configuration to file') + subparsers.add_parser('restore-shares', + help='Restore share configuration from file') + subparsers.required = True return parser.parse_args() -def _share_conf(parameters, **kwargs): - """Run samba registry edit command.""" +def _conf_command(parameters, **kwargs): + """Run samba configuration registry command.""" subprocess.check_call(['net', 'conf'] + parameters, **kwargs) -def _create_open_share(name, path): - """Create an open samba share.""" +def _create_share(mount_point, windows_filesystem=False): + """Create a samba share.""" + open_share_path = os.path.join(mount_point, SHARES_PATH, 'open_share') + os.makedirs(open_share_path, exist_ok=True) + + # FAT and NTFS partitions don't support chown and chmod + if not windows_filesystem: + shutil.chown(open_share_path, group='sambashare') + os.chmod(open_share_path, 0o2775) + + share_name = _create_share_name(mount_point) + _define_open_share(share_name, open_share_path, windows_filesystem) + + +def _define_open_share(name, path, windows_filesystem=False): + """Define an open samba share.""" try: - _share_conf(['delshare', name], stderr=subprocess.DEVNULL) + _conf_command(['delshare', name], stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: pass - _share_conf(['addshare', name, path, 'writeable=y', 'guest_ok=y']) - _share_conf(['setparm', name, 'force group', 'sambashare']) - _share_conf(['setparm', name, 'inherit permissions', 'yes']) + _conf_command(['addshare', name, path, 'writeable=y', 'guest_ok=y']) + if not windows_filesystem: + _conf_command(['setparm', name, 'force group', 'sambashare']) + _conf_command(['setparm', name, 'inherit permissions', 'yes']) -def _use_config_file(conf): +def _create_share_name(mount_point): + """Create a share name.""" + share_name = os.path.basename(mount_point) + if not share_name: + share_name = "disk" + + return share_name + + +def _get_shares(): + """Get shares""" + shares = [] + command = ['net', 'conf', 'list'] + output = subprocess.check_output(command) + config = configparser.ConfigParser() + config.read_string(output.decode()) + for name in config.sections(): + mount_point = config[name]['path'].split(SHARES_PATH)[0] + mount_point = os.path.normpath(mount_point) + shares.append(dict(name=name, mount_point=mount_point)) + + return shares + + +def _use_config_file(conf_file): """Set samba configuration file location.""" aug = augeas.Augeas( flags=augeas.Augeas.NO_LOAD + augeas.Augeas.NO_MODL_AUTOLOAD) @@ -94,41 +157,74 @@ def _use_config_file(conf): aug.load() aug.set('/files' + DEFAULT_FILE + '/SMBDOPTIONS', - '--configfile={0}'.format(conf)) + '--configfile={0}'.format(conf_file)) aug.save() +def _validate_mount_point(path): + """Validate that given path string is a mount point.""" + if path != '/': + parent_path = os.path.dirname(path) + if os.stat(path).st_dev == os.stat(parent_path).st_dev: + raise RuntimeError('Path "{0}" is not a mount point.'.format(path)) + + +def subcommand_add_share(arguments): + """Create a samba share.""" + mount_point = os.path.normpath(arguments.mount_point) + _validate_mount_point(mount_point) + _create_share(mount_point, arguments.windows_filesystem) + + +def subcommand_delete_share(arguments): + """Delete a samba share configuration.""" + mount_point = os.path.normpath(arguments.mount_point) + shares = _get_shares() + for share in shares: + if share['mount_point'] == mount_point: + _conf_command(['delshare', share['name']]) + # restart samba to disconnect all users + action_utils.service_restart('smbd') + break + else: + raise RuntimeError( + 'Mount path "{0}" is not shared.'.format(mount_point)) + + +def subcommand_get_shares(_): + """Get samba shares.""" + print(json.dumps(_get_shares())) + + def subcommand_setup(_): - """Configure samba after install.""" - try: - os.mkdir(SHARES_PATH) - except FileExistsError: - pass - - open_share_path = os.path.join(SHARES_PATH, 'open_share') - try: - os.mkdir(open_share_path) - except FileExistsError: - pass - # set folder group writable, 2 turns on the setGID bit - # - # TODO: some filesystems doesn't support chown and chmod - # (and it is not needed if mounted with correct parameters) - shutil.chown(open_share_path, group='sambashare') - os.chmod(open_share_path, 0o2775) - - # use custom samba config file + """Configure samba. Use custom samba config file.""" with open(CONF_PATH, 'w') as file_handle: file_handle.write(CONF) _use_config_file(CONF_PATH) - _create_open_share('freedombox-open-share', open_share_path) - action_utils.service_restart('smbd') + if action_utils.service_is_running('smbd'): + action_utils.service_restart('smbd') + + +def subcommand_dump_shares(_): + """Dump registy share configuration.""" + os.makedirs(os.path.dirname(SHARES_CONF_BACKUP_FILE), exist_ok=True) + with open(SHARES_CONF_BACKUP_FILE, 'w') as backup_file: + command = ['net', 'conf', 'list'] + subprocess.run(command, stdout=backup_file, check=True) + + +def subcommand_restore_shares(_): + """Restore registy share configuration.""" + if not os.path.exists(SHARES_CONF_BACKUP_FILE): + raise RuntimeError( + 'Backup file {0} does not exist.'.format(SHARES_CONF_BACKUP_FILE)) + _conf_command(['drop']) + _conf_command(['import', SHARES_CONF_BACKUP_FILE]) def main(): """Parse arguments and perform all duties.""" arguments = parse_arguments() - subcommand = arguments.subcommand.replace('-', '_') subcommand_method = globals()['subcommand_' + subcommand] subcommand_method(arguments) diff --git a/plinth/modules/samba/__init__.py b/plinth/modules/samba/__init__.py index 4ec03b8be..f18d946a4 100644 --- a/plinth/modules/samba/__init__.py +++ b/plinth/modules/samba/__init__.py @@ -18,6 +18,7 @@ FreedomBox app to configure samba. """ +import json import socket from django.urls import reverse_lazy @@ -34,7 +35,7 @@ from .manifest import backup, clients # noqa, pylint: disable=unused-import version = 1 -managed_services = ['smbd'] +managed_services = ['smbd', 'nmbd'] managed_packages = ['samba'] @@ -43,12 +44,13 @@ name = _('Samba') short_description = _('Samba File Sharing') description = [ - _('Samba file sharing allows to share files between computers in your ' - 'local network. '), + _('Samba allows to share files and folders between computers in your ' + 'local network.'), format_lazy( - _('If enabled, Samba share will be available at \\\\{hostname} on ' - 'Windows and smb://{hostname} on Linux and Mac'), - hostname=socket.gethostname()), + _('After installation, you can choose which disks to use for sharing. ' + 'Enabled {hostname} shares are open to everyone in your local ' + 'network and are accessible under Network section in the file ' + 'manager.'), hostname=socket.gethostname().upper()) ] clients = clients @@ -81,6 +83,9 @@ class SambaApp(app_module.App): daemon = Daemon('daemon-samba', managed_services[0]) self.add(daemon) + daemon_nmbd = Daemon('daemon-samba-nmbd', managed_services[1]) + self.add(daemon_nmbd) + def init(): """Initialize the module.""" @@ -111,3 +116,35 @@ def diagnose(): results.append(action_utils.diagnose_port_listening(445, 'tcp6')) return results + + +def add_share(mount_point, filesystem): + """Add a share.""" + command = ['add-share', '--mount-point', mount_point] + if filesystem in ['ntfs', 'vfat']: + command = command + ['--windows-filesystem'] + actions.superuser_run('samba', command) + + +def delete_share(mount_point): + """Delete a share.""" + command = ['delete-share', '--mount-point', mount_point] + actions.superuser_run('samba', command) + + +def get_shares(): + """Get defined shares.""" + output = actions.superuser_run('samba', ['get-shares']) + + return json.loads(output) + + +def backup_pre(packet): + """Save registry share configuration.""" + actions.superuser_run('samba', ['dump-shares']) + + +def restore_post(packet): + """Restore configuration.""" + actions.superuser_run('samba', ['setup']) + actions.superuser_run('samba', ['restore-config']) diff --git a/plinth/modules/samba/manifest.py b/plinth/modules/samba/manifest.py index 8275de1da..8f50e3263 100644 --- a/plinth/modules/samba/manifest.py +++ b/plinth/modules/samba/manifest.py @@ -14,10 +14,22 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # +""" +Application manifest for Samba. +""" from plinth.clients import validate from plinth.modules.backups.api import validate as validate_backup +# A directory where the 'open_share' subdirectory will be created +SHARES_PATH = 'FreedomBox/shares/' +SHARES_CONF_BACKUP_FILE = '/var/lib/plinth/backups-data/samba-shares-dump.conf' + clients = validate([]) -backup = validate_backup({}) +backup = validate_backup({ + 'data': { + 'files': [SHARES_CONF_BACKUP_FILE] + }, + 'services': ['smbd', 'nmbd'] +}) diff --git a/plinth/modules/samba/static/samba.js b/plinth/modules/samba/static/samba.js new file mode 100644 index 000000000..abeba7b4c --- /dev/null +++ b/plinth/modules/samba/static/samba.js @@ -0,0 +1,7 @@ +const share_checkbox = $(".shareform > input[type='checkbox']"); + +share_checkbox.change(function(event) { + this.disabled=true; + this.style.cursor='wait'; + this.form.submit(); +}); diff --git a/plinth/modules/samba/templates/samba.html b/plinth/modules/samba/templates/samba.html new file mode 100644 index 000000000..2e849d70c --- /dev/null +++ b/plinth/modules/samba/templates/samba.html @@ -0,0 +1,139 @@ +{% extends "app.html" %} +{% comment %} +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} +{% load static %} + +{% block page_head %} + +{% endblock %} + + +{% block configuration %} + {{ block.super }} + + {% if is_enabled %} +

{% trans "Select devices for sharing:" %}

+

+ {% blocktrans trimmed %} + NB! Selecting device does not share the whole disk, only the folder + FreedomBox/shares/open_share will be shared on that disk. + {% endblocktrans %} +

+ + + + + + + + + + + + + {% for disk in disks %} + + + + + + + + + {% endfor %} + +
{% trans "Device" %}{% trans "Label" %}{% trans "Mount Point" %}{% trans "Type" %}{% trans "Used" %}
+ {% if disk.mount_point in shared_mounts %} +
+ {% csrf_token %} + +
+ {% else %} +
+ {% csrf_token %} + + +
+ {% endif %} +
{{ disk.device }}{{ disk.label|default_if_none:"" }}{{ disk.mount_point }}{{ disk.filesystem_type }} +
+ {% if disk.percent_used < 75 %} +
+ {{ disk.percent_used }}% +
+
+
{{ disk.used_str }} / {{ disk.size_str }}
+
+ + {% if unavailable_shares %} +

Shares configured but the disk is not available:

+

+ {% blocktrans trimmed %} + If the disk is plugged back in, sharing will be automatically enabled. + {% endblocktrans %} +

+ + + + + + + + + {% for share in unavailable_shares %} + + + + + {% endfor %} + +
{% trans "Share name" %}{% trans "Action" %}
{{ share.name }} +
+ {% csrf_token %} + +
+
+ {% endif %} + {% endif %} +{% endblock %} + +{% block page_js %} + +{% endblock %} diff --git a/plinth/modules/samba/urls.py b/plinth/modules/samba/urls.py index a439d9d49..d98f384a4 100644 --- a/plinth/modules/samba/urls.py +++ b/plinth/modules/samba/urls.py @@ -15,18 +15,17 @@ # along with this program. If not, see . # """ -URLs for the Samba module. +URLs for the samba module. """ from django.conf.urls import url -from plinth.modules import samba -from plinth.views import AppView + +from . import views urlpatterns = [ - url( - r'^apps/samba/$', - AppView.as_view(app_id='samba', name=samba.name, - diagnostics_module_name='samba', - description=samba.description, - show_status_block=False), name='index') + url(r'^samba/$', views.SambaAppView.as_view(), name='index'), + url(r'^samba/share/(?P[A-Za-z0-9%_.\-~]+)/$', views.share, + name='share'), + url(r'^samba/unshare/(?P[A-Za-z0-9%_.\-~]+)/$', views.unshare, + name='unshare'), ] diff --git a/plinth/modules/samba/views.py b/plinth/modules/samba/views.py new file mode 100644 index 000000000..6132340d5 --- /dev/null +++ b/plinth/modules/samba/views.py @@ -0,0 +1,102 @@ +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Views for samba module. +""" + +import logging +import urllib.parse + +from django.contrib import messages +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_POST +from plinth import views +from plinth.modules import samba, storage + +logger = logging.getLogger(__name__) + + +class SambaAppView(views.AppView): + """Samba sharing basic configuration.""" + name = samba.name + description = samba.description + app_id = 'samba' + template_name = 'samba.html' + + def get_context_data(self, *args, **kwargs): + """Return template context data.""" + context = super().get_context_data(*args, **kwargs) + disks = storage.get_disks() + context['disks'] = disks + shares = samba.get_shares() + context['shared_mounts'] = [share['mount_point'] for share in shares] + + unavailable_shares = [] + for share in shares: + for disk in disks: + if share['mount_point'] == disk['mount_point']: + break + else: + unavailable_shares.append(share) + context['unavailable_shares'] = unavailable_shares + + return context + + +@require_POST +def share(request, mount_point): + """Enable sharing, given its root path. + + mount_point is urlquoted. + + """ + mount_point = urllib.parse.unquote(mount_point) + filesystem = request.POST.get('filesystem_type', '') + try: + samba.add_share(mount_point, filesystem) + messages.success(request, _('Share enabled.')) + except Exception as exception: + logger.exception('Error enabling share') + messages.error( + request, + _('Error enabling share: {error_message}').format( + error_message=exception)) + + return redirect(reverse('samba:index')) + + +@require_POST +def unshare(request, mount_point): + """Disable sharing, given its name. + + mount_point is urlquoted. + + """ + mount_point = urllib.parse.unquote(mount_point) + try: + samba.delete_share(mount_point) + messages.success(request, _('Share disabled.')) + except Exception as exception: + logger.exception('Error disabling share') + messages.error( + request, + _('Error disabling share: {error_message}').format( + error_message=exception)) + + return redirect(reverse('samba:index'))