def test__schema_unixperm_null(): @accepts(UnixPerm('data', null=True)) def unixpermnull(self, data): return data self = Mock() assert unixpermnull(self, None) is None
class SharingAFPService(CRUDService): class Config: namespace = 'sharing.afp' datastore = 'sharing.afp_share' datastore_prefix = 'afp_' datastore_extend = 'sharing.afp.extend' @accepts(Dict( 'sharingafp_create', Str('path'), Bool('home', default=False), Str('name'), Str('comment'), List('allow', default=[]), List('deny', default=[]), List('ro', default=[]), List('rw', default=[]), Bool('timemachine', default=False), Int('timemachine_quota', default=0), Bool('nodev', default=False), Bool('nostat', default=False), Bool('upriv', default=True), UnixPerm('fperm', default='644'), UnixPerm('dperm', default='755'), UnixPerm('umask', default='000'), List('hostsallow', items=[IPAddr('ip', network=True)], default=[]), List('hostsdeny', items=[IPAddr('ip', network=True)], default=[]), Str('auxparams'), register=True )) async def do_create(self, data): verrors = ValidationErrors() path = data['path'] await self.clean(data, 'sharingafp_create', verrors) await self.validate(data, 'sharingafp_create', verrors) await check_path_resides_within_volume( verrors, self.middleware, "sharingafp_create.path", path) if verrors: raise verrors if path and not os.path.exists(path): try: os.makedirs(path) except OSError as e: raise CallError(f'Failed to create {path}: {e}') await self.compress(data) data['id'] = await self.middleware.call( 'datastore.insert', self._config.datastore, data, {'prefix': self._config.datastore_prefix}) await self.extend(data) await self._service_change('afp', 'reload') return data @accepts( Int('id'), Patch( 'sharingafp_create', 'sharingafp_update', ('attr', {'update': True}) ) ) async def do_update(self, id, data): verrors = ValidationErrors() old = await self.middleware.call( 'datastore.query', self._config.datastore, [('id', '=', id)], {'extend': self._config.datastore_extend, 'prefix': self._config.datastore_prefix, 'get': True}) path = data.get('path') new = old.copy() new.update(data) await self.clean(new, 'sharingafp_update', verrors, id=id) await self.validate(new, 'sharingafp_update', verrors, old=old) if path: await check_path_resides_within_volume( verrors, self.middleware, "sharingafp_create.path", path) if verrors: raise verrors if path and not os.path.exists(path): try: os.makedirs(path) except OSError as e: raise CallError(f'Failed to create {path}: {e}') await self.compress(new) await self.middleware.call( 'datastore.update', self._config.datastore, id, new, {'prefix': self._config.datastore_prefix}) await self.extend(new) await self._service_change('afp', 'reload') return new @accepts(Int('id')) async def do_delete(self, id): result = await self.middleware.call('datastore.delete', self._config.datastore, id) await self._service_change('afp', 'reload') return result @private async def clean(self, data, schema_name, verrors, id=None): data['name'] = await self.name_exists(data, schema_name, verrors, id) @private async def validate(self, data, schema_name, verrors, old=None): await self.home_exists(data['home'], schema_name, verrors, old) @private async def home_exists(self, home, schema_name, verrors, old=None): home_filters = [('home', '=', True)] home_result = None if home: if old and old['id'] is not None: id = old['id'] if not old['home']: home_filters.append(('id', '!=', id)) # The user already had this set as the home share home_result = await self.middleware.call( 'datastore.query', self._config.datastore, home_filters, {'prefix': self._config.datastore_prefix}) if home_result: verrors.add(f'{schema_name}.home', 'Only one share is allowed to be a home share.') @private async def name_exists(self, data, schema_name, verrors, id=None): name = data['name'] path = data['path'] home = data['home'] name_filters = [('name', '=', name)] path_filters = [('path', '=', path)] if not name: if home: name = 'Homes' else: name = path.rsplit('/', 1)[-1] if id is not None: name_filters.append(('id', '!=', id)) path_filters.append(('id', '!=', id)) name_result = await self.middleware.call( 'datastore.query', self._config.datastore, name_filters, {'prefix': self._config.datastore_prefix}) path_result = await self.middleware.call( 'datastore.query', self._config.datastore, path_filters, {'prefix': self._config.datastore_prefix}) if name_result: verrors.add(f'{schema_name}.name', 'A share with this name already exists.') if path_result: verrors.add(f'{schema_name}.path', 'A share with this path already exists.') return name @private async def extend(self, data): data['allow'] = data['allow'].split() data['deny'] = data['deny'].split() data['ro'] = data['ro'].split() data['rw'] = data['rw'].split() data['hostsallow'] = data['hostsallow'].split() data['hostsdeny'] = data['hostsdeny'].split() return data @private async def compress(self, data): data['allow'] = ' '.join(data['allow']) data['deny'] = ' '.join(data['deny']) data['ro'] = ' '.join(data['ro']) data['rw'] = ' '.join(data['rw']) data['hostsallow'] = ' '.join(data['hostsallow']) data['hostsdeny'] = ' '.join(data['hostsdeny']) return data
class FilesystemService(Service): @accepts(Str('path', required=True), Ref('query-filters'), Ref('query-options')) def listdir(self, path, filters=None, options=None): """ Get the contents of a directory. Each entry of the list consists of: name(str): name of the file path(str): absolute path of the entry realpath(str): absolute real path of the entry (if SYMLINK) type(str): DIRECTORY | FILESYSTEM | SYMLINK | OTHER size(int): size of the entry mode(int): file mode/permission uid(int): user id of entry owner gid(int): group id of entry onwer """ if not os.path.exists(path): raise CallError(f'Directory {path} does not exist', errno.ENOENT) if not os.path.isdir(path): raise CallError(f'Path {path} is not a directory', errno.ENOTDIR) rv = [] for entry in os.scandir(path): if entry.is_dir(): etype = 'DIRECTORY' elif entry.is_file(): etype = 'FILE' elif entry.is_symlink(): etype = 'SYMLINK' else: etype = 'OTHER' data = { 'name': entry.name, 'path': entry.path, 'realpath': os.path.realpath(entry.path) if etype == 'SYMLINK' else entry.path, 'type': etype, } try: stat = entry.stat() data.update({ 'size': stat.st_size, 'mode': stat.st_mode, 'uid': stat.st_uid, 'gid': stat.st_gid, }) except FileNotFoundError: data.update({'size': None, 'mode': None, 'uid': None, 'gid': None}) rv.append(data) return filter_list(rv, filters=filters or [], options=options or {}) @accepts(Str('path')) def stat(self, path): """ Return the filesystem stat(2) for a given `path`. """ try: stat = os.stat(path, follow_symlinks=False) except FileNotFoundError: raise CallError(f'Path {path} not found', errno.ENOENT) stat = { 'size': stat.st_size, 'mode': stat.st_mode, 'uid': stat.st_uid, 'gid': stat.st_gid, 'atime': stat.st_atime, 'mtime': stat.st_mtime, 'ctime': stat.st_ctime, 'dev': stat.st_dev, 'inode': stat.st_ino, 'nlink': stat.st_nlink, } try: stat['user'] = pwd.getpwuid(stat['uid']).pw_name except KeyError: stat['user'] = None try: stat['group'] = grp.getgrgid(stat['gid']).gr_name except KeyError: stat['group'] = None stat['acl'] = False if self.middleware.call_sync('filesystem.acl_is_trivial', path) else True return stat @private @accepts( Str('path'), Str('content'), Dict( 'options', Bool('append', default=False), Int('mode'), ), ) def file_receive(self, path, content, options=None): """ Simplified file receiving method for small files. `content` must be a base 64 encoded file content. DISCLAIMER: DO NOT USE THIS FOR BIG FILES (> 500KB). """ options = options or {} dirname = os.path.dirname(path) if not os.path.exists(dirname): os.makedirs(dirname) if options.get('append'): openmode = 'ab' else: openmode = 'wb+' with open(path, openmode) as f: f.write(binascii.a2b_base64(content)) mode = options.get('mode') if mode: os.chmod(path, mode) return True @private @accepts( Str('path'), Dict( 'options', Int('offset'), Int('maxlen'), ), ) def file_get_contents(self, path, options=None): """ Get contents of a file `path` in base64 encode. DISCLAIMER: DO NOT USE THIS FOR BIG FILES (> 500KB). """ options = options or {} if not os.path.exists(path): return None with open(path, 'rb') as f: if options.get('offset'): f.seek(options['offset']) data = binascii.b2a_base64(f.read(options.get('maxlen'))).decode().strip() return data @accepts(Str('path')) @job(pipes=["output"]) async def get(self, job, path): """ Job to get contents of `path`. """ if not os.path.isfile(path): raise CallError(f'{path} is not a file') with open(path, 'rb') as f: await self.middleware.run_in_thread(shutil.copyfileobj, f, job.pipes.output.w) @accepts( Str('path'), Dict( 'options', Bool('append', default=False), Int('mode'), ), ) @job(pipes=["input"]) async def put(self, job, path, options=None): """ Job to put contents to `path`. """ options = options or {} dirname = os.path.dirname(path) if not os.path.exists(dirname): os.makedirs(dirname) if options.get('append'): openmode = 'ab' else: openmode = 'wb+' with open(path, openmode) as f: await self.middleware.run_in_thread(shutil.copyfileobj, job.pipes.input.r, f) mode = options.get('mode') if mode: os.chmod(path, mode) return True @accepts(Str('path')) def statfs(self, path): """ Return stats from the filesystem of a given path. Raises: CallError(ENOENT) - Path not found """ try: statfs = bsd.statfs(path) except FileNotFoundError: raise CallError('Path not found.', errno.ENOENT) return { **statfs.__getstate__(), 'total_bytes': statfs.total_blocks * statfs.blocksize, 'free_bytes': statfs.free_blocks * statfs.blocksize, 'avail_bytes': statfs.avail_blocks * statfs.blocksize, } def __convert_to_basic_permset(self, permset): """ Convert "advanced" ACL permset format to basic format using bitwise operation and constants defined in py-bsd/bsd/acl.pyx, py-bsd/defs.pxd and acl.h. If the advanced ACL can't be converted without losing information, we return 'OTHER'. Reverse process converts the constant's value to a dictionary using a bitwise operation. """ perm = 0 for k, v, in permset.items(): if v: perm |= acl.NFS4Perm[k] try: SimplePerm = (acl.NFS4BasicPermset(perm)).name except Exception: SimplePerm = 'OTHER' return SimplePerm def __convert_to_basic_flagset(self, flagset): flags = 0 for k, v, in flagset.items(): if k == "INHERITED": continue if v: flags |= acl.NFS4Flag[k] try: SimpleFlag = (acl.NFS4BasicFlagset(flags)).name except Exception: SimpleFlag = 'OTHER' return SimpleFlag def __convert_to_adv_permset(self, basic_perm): permset = {} perm_mask = acl.NFS4BasicPermset[basic_perm].value for name, member in acl.NFS4Perm.__members__.items(): if perm_mask & member.value: permset.update({name: True}) else: permset.update({name: False}) return permset def __convert_to_adv_flagset(self, basic_flag): flagset = {} flag_mask = acl.NFS4BasicFlagset[basic_flag].value for name, member in acl.NFS4Flag.__members__.items(): if flag_mask & member.value: flagset.update({name: True}) else: flagset.update({name: False}) return flagset @accepts(Str('path')) def acl_is_trivial(self, path): """ ACL is trivial if it can be fully expressed as a file mode without losing any access rules. This is intended to be used as a check before allowing users to chmod() through the webui """ if not os.path.exists(path): raise CallError('Path not found.', errno.ENOENT) a = acl.ACL(file=path) return a.is_trivial @accepts( Dict( 'filesystem_ownership', Str('path', required=True), Int('uid', null=True, default=None), Int('gid', null=True, default=None), Dict( 'options', Bool('recursive', default=False), Bool('traverse', default=False) ) ) ) def chown(self, data): """ Change owner or group of file at `path`. `uid` and `gid` specify new owner of the file. If either key is absent or None, then existing value on the file is not changed. `recursive` performs action recursively, but does not traverse filesystem mount points. If `traverse` and `recursive` are specified, then the chown operation will traverse filesystem mount points. """ uid = -1 if data['uid'] is None else data['uid'] gid = -1 if data['gid'] is None else data['gid'] options = data['options'] if not options['recursive']: os.chown(data['path'], uid, gid) else: winacl = subprocess.run([ '/usr/local/bin/winacl', '-a', 'chown', '-O', str(uid), '-G', str(gid), '-rx' if options['traverse'] else '-r', '-p', data['path']], check=False, capture_output=True ) if winacl.returncode != 0: raise CallError(f"Failed to recursively change ownership: {winacl.stderr.decode()}") @accepts( Dict( 'filesystem_permission', Str('path', required=True), UnixPerm('mode', null=True), Int('uid', null=True, default=None), Int('gid', null=True, default=None), Dict( 'options', Bool('stripacl', default=False), Bool('recursive', default=False), Bool('traverse', default=False), ) ) ) @job(lock=lambda args: f'setperm:{args[0]}') def setperm(self, job, data): """ Remove extended ACL from specified path. If `mode` is specified then the mode will be applied to the path and files and subdirectories depending on which `options` are selected. Mode should be formatted as string representation of octal permissions bits. `stripacl` setperm will fail if an extended ACL is present on `path`, unless `stripacl` is set to True. `recursive` remove ACLs recursively, but do not traverse dataset boundaries. `traverse` remove ACLs from child datasets. If no `mode` is set, and `stripacl` is True, then non-trivial ACLs will be converted to trivial ACLs. An ACL is trivial if it can be expressed as a file mode without losing any access rules. """ options = data['options'] mode = data.get('mode', None) uid = -1 if data['uid'] is None else data['uid'] gid = -1 if data['gid'] is None else data['gid'] if not os.path.exists(data['path']): raise CallError('Path not found.', errno.ENOENT) acl_is_trivial = self.middleware.call_sync('filesystem.acl_is_trivial', data['path']) if not acl_is_trivial and not options['stripacl']: raise CallError( f'Non-trivial ACL present on [{data["path"]}]. Option "stripacl" required to change permission.' ) if mode is not None: mode = int(mode, 8) a = acl.ACL(file=data['path']) a.strip() a.apply(data['path']) if mode: os.chmod(data['path'], mode) if uid or gid: os.chown(data['path'], uid, gid) if not options['recursive']: return winacl = subprocess.run([ '/usr/local/bin/winacl', '-a', 'clone' if mode else 'strip', '-O', str(uid), '-G', str(gid), '-rx' if options['traverse'] else '-r', '-p', data['path']], check=False, capture_output=True ) if winacl.returncode != 0: raise CallError(f"Failed to recursively apply ACL: {winacl.stderr.decode()}") @accepts( Str('path'), Bool('simplified', default=True), ) def getacl(self, path, simplified=True): """ Return ACL of a given path. Simplified returns a shortened form of the ACL permset and flags `TRAVERSE` sufficient rights to traverse a directory, but not read contents. `READ` sufficient rights to traverse a directory, and read file contents. `MODIFIY` sufficient rights to traverse, read, write, and modify a file. Equivalent to modify_set. `FULL_CONTROL` all permissions. If the permisssions do not fit within one of the pre-defined simplified permissions types, then the full ACL entry will be returned. In all cases we replace USER_OBJ, GROUP_OBJ, and EVERYONE with owner@, group@, everyone@ for consistency with getfacl and setfacl. If one of aforementioned special tags is used, 'id' must be set to None. An inheriting empty everyone@ ACE is appended to non-trivial ACLs in order to enforce Windows expectations regarding permissions inheritance. This entry is removed from NT ACL returned to SMB clients when 'ixnas' samba VFS module is enabled. We also remove it here to avoid confusion. """ if not os.path.exists(path): raise CallError('Path not found.', errno.ENOENT) stat = os.stat(path) a = acl.ACL(file=path) fs_acl = a.__getstate__() if not simplified: advanced_acl = [] for entry in fs_acl: ace = { 'tag': (acl.ACLWho[entry['tag']]).value, 'id': entry['id'], 'type': entry['type'], 'perms': entry['perms'], 'flags': entry['flags'], } if ace['tag'] == 'everyone@' and self.__convert_to_basic_permset(ace['perms']) == 'NOPERMS': continue advanced_acl.append(ace) return {'uid': stat.st_uid, 'gid': stat.st_gid, 'acl': advanced_acl} if simplified: simple_acl = [] for entry in fs_acl: ace = { 'tag': (acl.ACLWho[entry['tag']]).value, 'id': entry['id'], 'type': entry['type'], 'perms': {'BASIC': self.__convert_to_basic_permset(entry['perms'])}, 'flags': {'BASIC': self.__convert_to_basic_flagset(entry['flags'])}, } if ace['tag'] == 'everyone@' and ace['perms']['BASIC'] == 'NOPERMS': continue for key in ['perms', 'flags']: if ace[key]['BASIC'] == 'OTHER': ace[key] = entry[key] simple_acl.append(ace) return {'uid': stat.st_uid, 'gid': stat.st_gid, 'acl': simple_acl} @accepts( Dict( 'filesystem_acl', Str('path', required=True), List( 'dacl', items=[ Dict( 'aclentry', Str('tag', enum=['owner@', 'group@', 'everyone@', 'USER', 'GROUP']), Int('id', null=True), Str('type', enum=['ALLOW', 'DENY']), Dict( 'perms', Bool('READ_DATA'), Bool('WRITE_DATA'), Bool('APPEND_DATA'), Bool('READ_NAMED_ATTRS'), Bool('WRITE_NAMED_ATTRS'), Bool('EXECUTE'), Bool('DELETE_CHILD'), Bool('READ_ATTRIBUTES'), Bool('WRITE_ATTRIBUTES'), Bool('DELETE'), Bool('READ_ACL'), Bool('WRITE_ACL'), Bool('WRITE_OWNER'), Bool('SYNCHRONIZE'), Str('BASIC', enum=['FULL_CONTROL', 'MODIFY', 'READ', 'TRAVERSE']), ), Dict( 'flags', Bool('FILE_INHERIT'), Bool('DIRECTORY_INHERIT'), Bool('NO_PROPAGATE_INHERIT'), Bool('INHERIT_ONLY'), Bool('INHERITED'), Str('BASIC', enum=['INHERIT', 'NOINHERIT']), ), ) ], default=[] ), Int('uid', null=True, default=None), Int('gid', null=True, default=None), Dict( 'options', Bool('stripacl', default=False), Bool('recursive', default=False), Bool('traverse', default=False), ) ) ) @job(lock=lambda args: f'setacl:{args[0]}') def setacl(self, job, data): """ Set ACL of a given path. Takes the following parameters: `path` full path to directory or file. `dacl` "simplified" ACL here or a full ACL. `uid` the desired UID of the file user. If set to -1, then UID is not changed. `gid` the desired GID of the file group. If set to -1 then GID is not changed. `recursive` apply the ACL recursively `traverse` traverse filestem boundaries (ZFS datasets) `strip` convert ACL to trivial. ACL is trivial if it can be expressed as a file mode without losing any access rules. In all cases we replace USER_OBJ, GROUP_OBJ, and EVERYONE with owner@, group@, everyone@ for consistency with getfacl and setfacl. If one of aforementioned special tags is used, 'id' must be set to None. An inheriting empty everyone@ ACE is appended to non-trivial ACLs in order to enforce Windows expectations regarding permissions inheritance. This entry is removed from NT ACL returned to SMB clients when 'ixnas' samba VFS module is enabled. """ options = data['options'] dacl = data.get('dacl', []) if not os.path.exists(data['path']): raise CallError('Path not found.', errno.ENOENT) if dacl and options['stripacl']: raise CallError('Setting ACL and stripping ACL are not permitted simultaneously.', errno.EINVAL) uid = -1 if data.get('uid', None) is None else data['uid'] gid = -1 if data.get('gid', None) is None else data['gid'] if options['stripacl']: a = acl.ACL(file=data['path']) a.strip() a.apply(data['path']) else: cleaned_acl = [] lockace_is_present = False for entry in dacl: if entry['perms'].get('BASIC') == 'OTHER' or entry['flags'].get('BASIC') == 'OTHER': raise CallError('Unable to apply simplified ACL due to OTHER entry. Use full ACL.', errno.EINVAL) ace = { 'tag': (acl.ACLWho(entry['tag'])).name, 'id': entry['id'], 'type': entry['type'], 'perms': self.__convert_to_adv_permset(entry['perms']['BASIC']) if 'BASIC' in entry['perms'] else entry['perms'], 'flags': self.__convert_to_adv_flagset(entry['flags']['BASIC']) if 'BASIC' in entry['flags'] else entry['flags'], } if ace['tag'] == 'EVERYONE' and self.__convert_to_basic_permset(ace['perms']) == 'NOPERMS': lockace_is_present = True cleaned_acl.append(ace) if not lockace_is_present: locking_ace = { 'tag': 'EVERYONE', 'id': None, 'type': 'ALLOW', 'perms': self.__convert_to_adv_permset('NOPERMS'), 'flags': self.__convert_to_adv_flagset('INHERIT') } cleaned_acl.append(locking_ace) a = acl.ACL() a.__setstate__(cleaned_acl) a.apply(data['path']) if not options['recursive']: return True winacl = subprocess.run([ '/usr/local/bin/winacl', '-a', 'clone', '-O', str(uid), '-G', str(gid), '-rx' if options['traverse'] else '-r', '-p', data['path']], check=False, capture_output=True ) if winacl.returncode != 0: raise CallError(f"Failed to recursively apply ACL: {winacl.stderr.decode()}")
class SharingAFPService(SharingService): share_task_type = 'AFP' class Config: namespace = 'sharing.afp' datastore = 'sharing.afp_share' datastore_prefix = 'afp_' datastore_extend = 'sharing.afp.extend' @accepts(Dict( 'sharingafp_create', Str('path', required=True), Bool('home', default=False), Str('name'), Str('comment'), List('allow', default=[]), List('deny', default=[]), List('ro', default=[]), List('rw', default=[]), Bool('timemachine', default=False), Int('timemachine_quota', default=0), Bool('nodev', default=False), Bool('nostat', default=False), Bool('upriv', default=True), UnixPerm('fperm', default='644'), UnixPerm('dperm', default='755'), UnixPerm('umask', default='000'), List('hostsallow', items=[], default=[]), List('hostsdeny', items=[], default=[]), Str('vuid', null=True, default=''), Str('auxparams', max_length=None), Bool('enabled', default=True), register=True )) async def do_create(self, data): """ Create AFP share. `allow`, `deny`, `ro`, and `rw` are lists of users and groups. Groups are designated by an @ prefix. `hostsallow` and `hostsdeny` are lists of hosts and/or networks. """ verrors = ValidationErrors() path = data['path'] await self.clean(data, 'sharingafp_create', verrors) await self.validate(data, 'sharingafp_create', verrors) verrors.check() if path and not os.path.exists(path): try: os.makedirs(path) except OSError as e: raise CallError(f'Failed to create {path}: {e}') await self.compress(data) data['id'] = await self.middleware.call( 'datastore.insert', self._config.datastore, data, {'prefix': self._config.datastore_prefix}) await self._service_change('afp', 'reload') return await self.get_instance(data['id']) @accepts( Int('id'), Patch( 'sharingafp_create', 'sharingafp_update', ('attr', {'update': True}) ) ) async def do_update(self, id, data): """ Update AFP share `id`. """ verrors = ValidationErrors() old = await self.middleware.call( 'datastore.query', self._config.datastore, [('id', '=', id)], {'extend': self._config.datastore_extend, 'prefix': self._config.datastore_prefix, 'get': True}) path = data.get('path') new = old.copy() new.update(data) await self.clean(new, 'sharingafp_update', verrors, id=id) await self.validate(new, 'sharingafp_update', verrors, old=old) verrors.check() if path and not os.path.exists(path): try: os.makedirs(path) except OSError as e: raise CallError(f'Failed to create {path}: {e}') await self.compress(new) await self.middleware.call( 'datastore.update', self._config.datastore, id, new, {'prefix': self._config.datastore_prefix}) await self._service_change('afp', 'reload') return await self.get_instance(id) @accepts(Int('id')) async def do_delete(self, id): """ Delete AFP share `id`. """ result = await self.middleware.call('datastore.delete', self._config.datastore, id) await self._service_change('afp', 'reload') return result @private async def clean(self, data, schema_name, verrors, id=None): data['name'] = await self.name_exists(data, schema_name, verrors, id) @private async def validate(self, data, schema_name, verrors, old=None): await self.home_exists(data['home'], schema_name, verrors, old) if data['vuid']: try: uuid.UUID(data['vuid'], version=4) except ValueError: verrors.add(f'{schema_name}.vuid', 'vuid must be a valid UUID.') await self.validate_path_field(data, schema_name, verrors) @private async def home_exists(self, home, schema_name, verrors, old=None): home_filters = [('home', '=', True)] home_result = None if home: if old and old['id'] is not None: id = old['id'] if not old['home']: home_filters.append(('id', '!=', id)) # The user already had this set as the home share home_result = await self.middleware.call( 'datastore.query', self._config.datastore, home_filters, {'prefix': self._config.datastore_prefix}) if home_result: verrors.add(f'{schema_name}.home', 'Only one share is allowed to be a home share.') @private async def name_exists(self, data, schema_name, verrors, id=None): name = data['name'] path = data['path'] home = data['home'] name_filters = [('name', '=', name)] path_filters = [('path', '=', path)] if not name: if home: name = 'Homes' else: name = path.rsplit('/', 1)[-1] if id is not None: name_filters.append(('id', '!=', id)) path_filters.append(('id', '!=', id)) name_result = await self.middleware.call( 'datastore.query', self._config.datastore, name_filters, {'prefix': self._config.datastore_prefix}) path_result = await self.middleware.call( 'datastore.query', self._config.datastore, path_filters, {'prefix': self._config.datastore_prefix}) if name_result: verrors.add(f'{schema_name}.name', 'A share with this name already exists.') if path_result: verrors.add(f'{schema_name}.path', 'A share with this path already exists.') return name @private async def extend(self, data): data['allow'] = data['allow'].split() data['deny'] = data['deny'].split() data['ro'] = data['ro'].split() data['rw'] = data['rw'].split() data['hostsallow'] = data['hostsallow'].split() data['hostsdeny'] = data['hostsdeny'].split() return data @private async def compress(self, data): data['allow'] = ' '.join(data['allow']) data['deny'] = ' '.join(data['deny']) data['ro'] = ' '.join(data['ro']) data['rw'] = ' '.join(data['rw']) data['hostsallow'] = ' '.join(data['hostsallow']) data['hostsdeny'] = ' '.join(data['hostsdeny']) if not data['vuid'] and data['timemachine']: data['vuid'] = str(uuid.uuid4()) data.pop(self.locked_field, None) return data
class FilesystemService(Service): @accepts(Str('path', required=True), Ref('query-filters'), Ref('query-options')) def listdir(self, path, filters=None, options=None): """ Get the contents of a directory. Each entry of the list consists of: name(str): name of the file path(str): absolute path of the entry realpath(str): absolute real path of the entry (if SYMLINK) type(str): DIRECTORY | FILESYSTEM | SYMLINK | OTHER size(int): size of the entry mode(int): file mode/permission uid(int): user id of entry owner gid(int): group id of entry onwer acl(bool): extended ACL is present on file """ if not os.path.exists(path): raise CallError(f'Directory {path} does not exist', errno.ENOENT) if not os.path.isdir(path): raise CallError(f'Path {path} is not a directory', errno.ENOTDIR) rv = [] for entry in os.scandir(path): if entry.is_symlink(): etype = 'SYMLINK' elif entry.is_dir(): etype = 'DIRECTORY' elif entry.is_file(): etype = 'FILE' else: etype = 'OTHER' data = { 'name': entry.name, 'path': entry.path, 'realpath': os.path.realpath(entry.path) if etype == 'SYMLINK' else entry.path, 'type': etype, } try: stat = entry.stat() data.update({ 'size': stat.st_size, 'mode': stat.st_mode, 'acl': False if self.acl_is_trivial(data["realpath"]) else True, 'uid': stat.st_uid, 'gid': stat.st_gid, }) except FileNotFoundError: data.update({'size': None, 'mode': None, 'acl': None, 'uid': None, 'gid': None}) rv.append(data) return filter_list(rv, filters=filters or [], options=options or {}) @accepts(Str('path')) def stat(self, path): """ Return the filesystem stat(2) for a given `path`. """ try: stat = os.stat(path, follow_symlinks=False) except FileNotFoundError: raise CallError(f'Path {path} not found', errno.ENOENT) stat = { 'size': stat.st_size, 'mode': stat.st_mode, 'uid': stat.st_uid, 'gid': stat.st_gid, 'atime': stat.st_atime, 'mtime': stat.st_mtime, 'ctime': stat.st_ctime, 'dev': stat.st_dev, 'inode': stat.st_ino, 'nlink': stat.st_nlink, } try: stat['user'] = pwd.getpwuid(stat['uid']).pw_name except KeyError: stat['user'] = None try: stat['group'] = grp.getgrgid(stat['gid']).gr_name except KeyError: stat['group'] = None stat['acl'] = False if self.middleware.call_sync('filesystem.acl_is_trivial', path) else True return stat @private @accepts( Str('path'), Str('content', max_length=2048000), Dict( 'options', Bool('append', default=False), Int('mode'), ), ) def file_receive(self, path, content, options=None): """ Simplified file receiving method for small files. `content` must be a base 64 encoded file content. """ options = options or {} dirname = os.path.dirname(path) if not os.path.exists(dirname): os.makedirs(dirname) if options.get('append'): openmode = 'ab' else: openmode = 'wb+' with open(path, openmode) as f: f.write(binascii.a2b_base64(content)) mode = options.get('mode') if mode: os.chmod(path, mode) return True @private @accepts( Str('path'), Dict( 'options', Int('offset'), Int('maxlen'), ), ) def file_get_contents(self, path, options=None): """ Get contents of a file `path` in base64 encode. DISCLAIMER: DO NOT USE THIS FOR BIG FILES (> 500KB). """ options = options or {} if not os.path.exists(path): return None with open(path, 'rb') as f: if options.get('offset'): f.seek(options['offset']) data = binascii.b2a_base64(f.read(options.get('maxlen'))).decode().strip() return data @accepts(Str('path')) @job(pipes=["output"]) async def get(self, job, path): """ Job to get contents of `path`. """ if not os.path.isfile(path): raise CallError(f'{path} is not a file') with open(path, 'rb') as f: await self.middleware.run_in_thread(shutil.copyfileobj, f, job.pipes.output.w) @accepts( Str('path'), Dict( 'options', Bool('append', default=False), Int('mode'), ), ) @job(pipes=["input"]) async def put(self, job, path, options=None): """ Job to put contents to `path`. """ options = options or {} dirname = os.path.dirname(path) if not os.path.exists(dirname): os.makedirs(dirname) if options.get('append'): openmode = 'ab' else: openmode = 'wb+' with open(path, openmode) as f: await self.middleware.run_in_thread(shutil.copyfileobj, job.pipes.input.r, f) mode = options.get('mode') if mode: os.chmod(path, mode) return True @accepts(Str('path')) def statfs(self, path): """ Return stats from the filesystem of a given path. Raises: CallError(ENOENT) - Path not found """ try: statfs = bsd.statfs(path) except FileNotFoundError: raise CallError('Path not found.', errno.ENOENT) return { **statfs.__getstate__(), 'total_bytes': statfs.total_blocks * statfs.blocksize, 'free_bytes': statfs.free_blocks * statfs.blocksize, 'avail_bytes': statfs.avail_blocks * statfs.blocksize, } def __convert_to_basic_permset(self, permset): """ Convert "advanced" ACL permset format to basic format using bitwise operation and constants defined in py-bsd/bsd/acl.pyx, py-bsd/defs.pxd and acl.h. If the advanced ACL can't be converted without losing information, we return 'OTHER'. Reverse process converts the constant's value to a dictionary using a bitwise operation. """ perm = 0 for k, v, in permset.items(): if v: perm |= acl.NFS4Perm[k] try: SimplePerm = (acl.NFS4BasicPermset(perm)).name except Exception: SimplePerm = 'OTHER' return SimplePerm def __convert_to_basic_flagset(self, flagset): flags = 0 for k, v, in flagset.items(): if k == "INHERITED": continue if v: flags |= acl.NFS4Flag[k] try: SimpleFlag = (acl.NFS4BasicFlagset(flags)).name except Exception: SimpleFlag = 'OTHER' return SimpleFlag def __convert_to_adv_permset(self, basic_perm): permset = {} perm_mask = acl.NFS4BasicPermset[basic_perm].value for name, member in acl.NFS4Perm.__members__.items(): if perm_mask & member.value: permset.update({name: True}) else: permset.update({name: False}) return permset def __convert_to_adv_flagset(self, basic_flag): flagset = {} flag_mask = acl.NFS4BasicFlagset[basic_flag].value for name, member in acl.NFS4Flag.__members__.items(): if flag_mask & member.value: flagset.update({name: True}) else: flagset.update({name: False}) return flagset def _winacl(self, path, action, uid, gid, options): chroot_dir = os.path.dirname(path) target = os.path.basename(path) winacl = subprocess.run([ '/usr/local/bin/winacl', '-a', action, '-O', str(uid), '-G', str(gid), '-rx' if options['traverse'] else '-r', '-c', chroot_dir, '-p', target], check=False, capture_output=True ) if winacl.returncode != 0: CallError(f"Winacl {action} on path {path} failed with error: [{winacl.stderr.decode().strip()}]") def _common_perm_path_validate(self, path): if not os.path.exists(path): raise CallError(f"Path not found: {path}", errno.ENOENT) if not os.path.realpath(path).startswith('/mnt/'): raise CallError(f"Changing permissions on paths outside of /mnt is not permitted: {path}", errno.EPERM) if os.path.realpath(path) in [x['path'] for x in self.middleware.call_sync('pool.query')]: raise CallError(f"Changing permissions of root level dataset is not permitted: {path}", errno.EPERM) @accepts(Str('path')) def acl_is_trivial(self, path): """ Returns True if the ACL can be fully expressed as a file mode without losing any access rules, or if the path does not support NFSv4 ACLs (for example a path on a tmpfs filesystem). """ if not os.path.exists(path): raise CallError(f'Path not found [{path}].', errno.ENOENT) has_nfs4_acl_support = os.pathconf(path, 64) if not has_nfs4_acl_support: return True return acl.ACL(file=path).is_trivial @accepts( Dict( 'filesystem_ownership', Str('path', required=True), Int('uid', null=True, default=None), Int('gid', null=True, default=None), Dict( 'options', Bool('recursive', default=False), Bool('traverse', default=False) ) ) ) @job(lock="perm_change") def chown(self, job, data): """ Change owner or group of file at `path`. `uid` and `gid` specify new owner of the file. If either key is absent or None, then existing value on the file is not changed. `recursive` performs action recursively, but does not traverse filesystem mount points. If `traverse` and `recursive` are specified, then the chown operation will traverse filesystem mount points. """ job.set_progress(0, 'Preparing to change owner.') self._common_perm_path_validate(data['path']) uid = -1 if data['uid'] is None else data['uid'] gid = -1 if data['gid'] is None else data['gid'] options = data['options'] if not options['recursive']: job.set_progress(100, 'Finished changing owner.') os.chown(data['path'], uid, gid) else: job.set_progress(10, f'Recursively changing owner of {data["path"]}.') self._winacl(data['path'], 'chown', uid, gid, options) job.set_progress(100, 'Finished changing owner.') @accepts( Dict( 'filesystem_permission', Str('path', required=True), UnixPerm('mode', null=True), Int('uid', null=True, default=None), Int('gid', null=True, default=None), Dict( 'options', Bool('stripacl', default=False), Bool('recursive', default=False), Bool('traverse', default=False), ) ) ) @job(lock="perm_change") def setperm(self, job, data): """ Remove extended ACL from specified path. If `mode` is specified then the mode will be applied to the path and files and subdirectories depending on which `options` are selected. Mode should be formatted as string representation of octal permissions bits. `uid` the desired UID of the file user. If set to None (the default), then user is not changed. `gid` the desired GID of the file group. If set to None (the default), then group is not changed. `stripacl` setperm will fail if an extended ACL is present on `path`, unless `stripacl` is set to True. `recursive` remove ACLs recursively, but do not traverse dataset boundaries. `traverse` remove ACLs from child datasets. If no `mode` is set, and `stripacl` is True, then non-trivial ACLs will be converted to trivial ACLs. An ACL is trivial if it can be expressed as a file mode without losing any access rules. """ job.set_progress(0, 'Preparing to set permissions.') options = data['options'] mode = data.get('mode', None) uid = -1 if data['uid'] is None else data['uid'] gid = -1 if data['gid'] is None else data['gid'] self._common_perm_path_validate(data['path']) acl_is_trivial = self.middleware.call_sync('filesystem.acl_is_trivial', data['path']) if not acl_is_trivial and not options['stripacl']: raise CallError( f'Non-trivial ACL present on [{data["path"]}]. Option "stripacl" required to change permission.', errno.EINVAL ) if mode is not None: mode = int(mode, 8) a = acl.ACL(file=data['path']) a.strip() a.apply(data['path']) if mode: os.chmod(data['path'], mode) if uid or gid: os.chown(data['path'], uid, gid) if not options['recursive']: job.set_progress(100, 'Finished setting permissions.') return action = 'clone' if mode else 'strip' job.set_progress(10, f'Recursively setting permissions on {data["path"]}.') self._winacl(data['path'], action, uid, gid, options) job.set_progress(100, 'Finished setting permissions.') @accepts() async def default_acl_choices(self): """ Get list of default ACL types. """ acl_choices = [] for x in ACLDefault: if x.value['visible']: acl_choices.append(x.name) return acl_choices @accepts( Str('acl_type', default='OPEN', enum=[x.name for x in ACLDefault]), Str('share_type', default='NONE', enum=['NONE', 'AFP', 'SMB', 'NFS']), ) async def get_default_acl(self, acl_type, share_type): """ Returns a default ACL depending on the usage specified by `acl_type`. If an admin group is defined, then an entry granting it full control will be placed at the top of the ACL. Optionally may pass `share_type` to argument to get share-specific template ACL. """ acl = [] admin_group = (await self.middleware.call('smb.config'))['admin_group'] if acl_type == 'HOME' and (await self.middleware.call('activedirectory.get_state')) == 'HEALTHY': acl_type = 'DOMAIN_HOME' if admin_group: acl.append({ 'tag': 'GROUP', 'id': (await self.middleware.call('dscache.get_uncached_group', admin_group))['gr_gid'], 'perms': {'BASIC': 'FULL_CONTROL'}, 'flags': {'BASIC': 'INHERIT'}, 'type': 'ALLOW' }) if share_type == 'SMB': acl.append({ 'tag': 'GROUP', 'id': int(SMBBuiltin['USERS'].value[1][9:]), 'perms': {'BASIC': 'MODIFY'}, 'flags': {'BASIC': 'INHERIT'}, 'type': 'ALLOW' }) acl.extend((ACLDefault[acl_type].value)['acl']) return acl def _is_inheritable(self, flags): """ Takes ACE flags and return True if any inheritance bits are set. """ inheritance_flags = ['FILE_INHERIT', 'DIRECTORY_INHERIT', 'NO_PROPAGATE_INHERIT', 'INHERIT_ONLY'] for i in inheritance_flags: if flags.get(i): return True return False @private def canonicalize_acl_order(self, acl): """ Convert flags to advanced, then separate the ACL into two lists. One for ACEs that have been inherited, one for aces that have not been inherited. Non-inherited ACEs take precedence and so they are placed first in finalized combined list. Within each list, the ACEs are orderd according to the following: 1) Deny ACEs that apply to the object itself (NOINHERIT) 2) Deny ACEs that apply to a subobject of the object (INHERIT) 3) Allow ACEs that apply to the object itself (NOINHERIT) 4) Allow ACEs that apply to a subobject of the object (INHERIT) See http://docs.microsoft.com/en-us/windows/desktop/secauthz/order-of-aces-in-a-dacl The "INHERITED" bit is stripped in filesystem.getacl when generating a BASIC flag type. It is best practice to use a non-simplified ACL for canonicalization. """ inherited_aces = [] final_acl = [] non_inherited_aces = [] for entry in acl: entry['flags'] = self.__convert_to_adv_flagset(entry['flags']['BASIC']) if 'BASIC' in entry['flags'] else entry['flags'] if entry['flags'].get('INHERITED'): inherited_aces.append(entry) else: non_inherited_aces.append(entry) if inherited_aces: inherited_aces = sorted( inherited_aces, key=lambda x: (x['type'] == 'ALLOW', self._is_inheritable(x['flags'])), ) if non_inherited_aces: non_inherited_aces = sorted( non_inherited_aces, key=lambda x: (x['type'] == 'ALLOW', self._is_inheritable(x['flags'])), ) final_acl = non_inherited_aces + inherited_aces return final_acl @accepts( Str('path'), Bool('simplified', default=True), ) def getacl(self, path, simplified=True): """ Return ACL of a given path. Simplified returns a shortened form of the ACL permset and flags `TRAVERSE` sufficient rights to traverse a directory, but not read contents. `READ` sufficient rights to traverse a directory, and read file contents. `MODIFIY` sufficient rights to traverse, read, write, and modify a file. Equivalent to modify_set. `FULL_CONTROL` all permissions. If the permisssions do not fit within one of the pre-defined simplified permissions types, then the full ACL entry will be returned. In all cases we replace USER_OBJ, GROUP_OBJ, and EVERYONE with owner@, group@, everyone@ for consistency with getfacl and setfacl. If one of aforementioned special tags is used, 'id' must be set to None. An inheriting empty everyone@ ACE is appended to non-trivial ACLs in order to enforce Windows expectations regarding permissions inheritance. This entry is removed from NT ACL returned to SMB clients when 'ixnas' samba VFS module is enabled. We also remove it here to avoid confusion. """ if not os.path.exists(path): raise CallError('Path not found.', errno.ENOENT) stat = os.stat(path) a = acl.ACL(file=path) fs_acl = a.__getstate__() if not simplified: advanced_acl = [] for entry in fs_acl: ace = { 'tag': (acl.ACLWho[entry['tag']]).value, 'id': entry['id'], 'type': entry['type'], 'perms': entry['perms'], 'flags': entry['flags'], } if ace['tag'] == 'everyone@' and self.__convert_to_basic_permset(ace['perms']) == 'NOPERMS': continue advanced_acl.append(ace) return {'uid': stat.st_uid, 'gid': stat.st_gid, 'acl': advanced_acl} if simplified: simple_acl = [] for entry in fs_acl: ace = { 'tag': (acl.ACLWho[entry['tag']]).value, 'id': entry['id'], 'type': entry['type'], 'perms': {'BASIC': self.__convert_to_basic_permset(entry['perms'])}, 'flags': {'BASIC': self.__convert_to_basic_flagset(entry['flags'])}, } if ace['tag'] == 'everyone@' and ace['perms']['BASIC'] == 'NOPERMS': continue for key in ['perms', 'flags']: if ace[key]['BASIC'] == 'OTHER': ace[key] = entry[key] simple_acl.append(ace) return {'uid': stat.st_uid, 'gid': stat.st_gid, 'acl': simple_acl} @accepts( Dict( 'filesystem_acl', Str('path', required=True), Int('uid', null=True, default=None), Int('gid', null=True, default=None), List( 'dacl', items=[ Dict( 'aclentry', Str('tag', enum=['owner@', 'group@', 'everyone@', 'USER', 'GROUP']), Int('id', null=True), Str('type', enum=['ALLOW', 'DENY']), Dict( 'perms', Bool('READ_DATA'), Bool('WRITE_DATA'), Bool('APPEND_DATA'), Bool('READ_NAMED_ATTRS'), Bool('WRITE_NAMED_ATTRS'), Bool('EXECUTE'), Bool('DELETE_CHILD'), Bool('READ_ATTRIBUTES'), Bool('WRITE_ATTRIBUTES'), Bool('DELETE'), Bool('READ_ACL'), Bool('WRITE_ACL'), Bool('WRITE_OWNER'), Bool('SYNCHRONIZE'), Str('BASIC', enum=['FULL_CONTROL', 'MODIFY', 'READ', 'TRAVERSE']), ), Dict( 'flags', Bool('FILE_INHERIT'), Bool('DIRECTORY_INHERIT'), Bool('NO_PROPAGATE_INHERIT'), Bool('INHERIT_ONLY'), Bool('INHERITED'), Str('BASIC', enum=['INHERIT', 'NOINHERIT']), ), ) ], default=[] ), Dict( 'options', Bool('stripacl', default=False), Bool('recursive', default=False), Bool('traverse', default=False), Bool('canonicalize', default=True) ) ) ) @job(lock="perm_change") def setacl(self, job, data): """ Set ACL of a given path. Takes the following parameters: `path` full path to directory or file. `dacl` "simplified" ACL here or a full ACL. `uid` the desired UID of the file user. If set to None (the default), then user is not changed. `gid` the desired GID of the file group. If set to None (the default), then group is not changed. `recursive` apply the ACL recursively `traverse` traverse filestem boundaries (ZFS datasets) `strip` convert ACL to trivial. ACL is trivial if it can be expressed as a file mode without losing any access rules. `canonicalize` reorder ACL entries so that they are in concanical form as described in the Microsoft documentation MS-DTYP 2.4.5 (ACL) In all cases we replace USER_OBJ, GROUP_OBJ, and EVERYONE with owner@, group@, everyone@ for consistency with getfacl and setfacl. If one of aforementioned special tags is used, 'id' must be set to None. An inheriting empty everyone@ ACE is appended to non-trivial ACLs in order to enforce Windows expectations regarding permissions inheritance. This entry is removed from NT ACL returned to SMB clients when 'ixnas' samba VFS module is enabled. """ job.set_progress(0, 'Preparing to set acl.') options = data['options'] dacl = data.get('dacl', []) self._common_perm_path_validate(data['path']) if dacl and options['stripacl']: raise CallError('Setting ACL and stripping ACL are not permitted simultaneously.', errno.EINVAL) uid = -1 if data.get('uid', None) is None else data['uid'] gid = -1 if data.get('gid', None) is None else data['gid'] if options['stripacl']: a = acl.ACL(file=data['path']) a.strip() a.apply(data['path']) else: inheritable_is_present = False cleaned_acl = [] lockace_is_present = False for entry in dacl: ace = { 'tag': (acl.ACLWho(entry['tag'])).name, 'id': entry['id'], 'type': entry['type'], 'perms': self.__convert_to_adv_permset(entry['perms']['BASIC']) if 'BASIC' in entry['perms'] else entry['perms'], 'flags': self.__convert_to_adv_flagset(entry['flags']['BASIC']) if 'BASIC' in entry['flags'] else entry['flags'], } if ace['flags'].get('INHERIT_ONLY') and not ace['flags'].get('DIRECTORY_INHERIT', False) and not ace['flags'].get('FILE_INHERIT', False): raise CallError( 'Invalid flag combination. DIRECTORY_INHERIT or FILE_INHERIT must be set if INHERIT_ONLY is set.', errno.EINVAL ) if ace['tag'] == 'EVERYONE' and self.__convert_to_basic_permset(ace['perms']) == 'NOPERMS': lockace_is_present = True elif ace['flags'].get('DIRECTORY_INHERIT') or ace['flags'].get('FILE_INHERIT'): inheritable_is_present = True cleaned_acl.append(ace) if not inheritable_is_present: raise CallError('At least one inheritable ACL entry is required', errno.EINVAL) if options['canonicalize']: cleaned_acl = self.canonicalize_acl_order(cleaned_acl) if not lockace_is_present: locking_ace = { 'tag': 'EVERYONE', 'id': None, 'type': 'ALLOW', 'perms': self.__convert_to_adv_permset('NOPERMS'), 'flags': self.__convert_to_adv_flagset('INHERIT') } cleaned_acl.append(locking_ace) a = acl.ACL() a.__setstate__(cleaned_acl) a.apply(data['path']) if not options['recursive']: os.chown(data['path'], uid, gid) job.set_progress(100, 'Finished setting ACL.') return job.set_progress(10, f'Recursively setting ACL on {data["path"]}.') self._winacl(data['path'], 'clone', uid, gid, options) job.set_progress(100, 'Finished setting ACL.') @private async def children_are_locked(self, path, child): if child["locked"] and path.startswith(child["mountpoint"]): return True if not path.startswith(child["mountpoint"]): return False if child.get("children"): for c in child["children"]: is_locked = await self.children_are_locked(path, c) if is_locked: return True return False @private async def path_is_encrypted(self, path): ds = await self.middleware.call("pool.dataset.from_path", path, True) if ds["locked"]: return True if not ds["children"]: return False return await self.children_are_locked(path, ds)
class PoolDatasetService(CRUDService): class Config: namespace = 'pool.dataset' @filterable def query(self, filters, options): # Otimization for cases in which they can be filtered at zfs.dataset.query zfsfilters = [] for f in filters: if len(f) == 3: if f[0] in ('id', 'name', 'pool', 'type'): zfsfilters.append(f) datasets = self.middleware.call_sync('zfs.dataset.query', zfsfilters, None) return filter_list(self.__transform(datasets), filters, options) def __transform(self, datasets): """ We need to transform the data zfs gives us to make it consistent/user-friendly, making it match whatever pool.dataset.{create,update} uses as input. """ def transform(dataset): for orig_name, new_name, method in ( ('org.freenas:description', 'comments', None), ('dedup', 'deduplication', str.upper), ('atime', None, str.upper), ('casesensitivity', None, str.upper), ('exec', None, str.upper), ('sync', None, str.upper), ('compression', None, str.upper), ('compressratio', None, None), ('origin', None, None), ('quota', None, _null), ('refquota', None, _null), ('reservation', None, _null), ('refreservation', None, _null), ('copies', None, None), ('snapdir', None, str.upper), ('readonly', None, str.upper), ('recordsize', None, None), ('sparse', None, None), ('volsize', None, None), ('volblocksize', None, None), ): if orig_name not in dataset['properties']: continue i = new_name or orig_name dataset[i] = dataset['properties'][orig_name] if method: dataset[i]['value'] = method(dataset[i]['value']) del dataset['properties'] if dataset['type'] == 'FILESYSTEM': dataset['share_type'] = self.middleware.call_sync( 'notifier.get_dataset_share_type', dataset['name'], ).upper() else: dataset['share_type'] = None rv = [] for child in dataset['children']: rv.append(transform(child)) dataset['children'] = rv return dataset rv = [] for dataset in datasets: rv.append(transform(dataset)) return rv @accepts( Dict( 'pool_dataset_create', Str('name', required=True), Str('type', enum=['FILESYSTEM', 'VOLUME'], default='FILESYSTEM'), Int('volsize'), # IN BYTES Str('volblocksize', enum=[ '512', '1K', '2K', '4K', '8K', '16K', '32K', '64K', '128K', ]), Bool('sparse'), Bool('force_size'), Str('comments'), Str('sync', enum=[ 'STANDARD', 'ALWAYS', 'DISABLED', ]), Str('compression', enum=[ 'OFF', 'LZ4', 'GZIP-1', 'GZIP-6', 'GZIP-9', 'ZLE', 'LZJB', ]), Str('atime', enum=['ON', 'OFF']), Str('exec', enum=['ON', 'OFF']), Int('quota'), Int('refquota'), Int('reservation'), Int('refreservation'), Int('copies'), Str('snapdir', enum=['VISIBLE', 'HIDDEN']), Str('deduplication', enum=['ON', 'VERIFY', 'OFF']), Str('readonly', enum=['ON', 'OFF']), Str('recordsize', enum=[ '512', '1K', '2K', '4K', '8K', '16K', '32K', '64K', '128K', '256K', '512K', '1024K', ]), Str('casesensitivity', enum=['SENSITIVE', 'INSENSITIVE', 'MIXED']), Str('share_type', enum=['UNIX', 'WINDOWS', 'MAC']), register=True, )) async def do_create(self, data): """ Creates a dataset/zvol. `volsize` is required for type=VOLUME and is supposed to be a multiple of the block size. """ verrors = ValidationErrors() if '/' not in data['name']: verrors.add('pool_dataset_create.name', 'You need a full name, e.g. pool/newdataset') else: await self.__common_validation(verrors, 'pool_dataset_create', data, 'CREATE') if verrors: raise verrors props = {} for i, real_name, transform in ( ('atime', None, str.lower), ('casesensitivity', None, str.lower), ('comments', 'org.freenas:description', None), ('compression', None, str.lower), ('copies', None, lambda x: str(x)), ('deduplication', 'dedup', str.lower), ('exec', None, str.lower), ('quota', None, _none), ('readonly', None, str.lower), ('recordsize', None, None), ('refquota', None, _none), ('refreservation', None, _none), ('reservation', None, _none), ('snapdir', None, str.lower), ('sparse', None, None), ('sync', None, str.lower), ('volblocksize', None, None), ('volsize', None, lambda x: str(x)), ): if i not in data: continue name = real_name or i props[name] = data[i] if not transform else transform(data[i]) await self.middleware.call('zfs.dataset.create', { 'name': data['name'], 'type': data['type'], 'properties': props, }) data['id'] = data['name'] await self.middleware.call('zfs.dataset.mount', data['name']) if data['type'] == 'FILESYSTEM': await self.middleware.call('notifier.change_dataset_share_type', data['name'], data.get('share_type', 'UNIX').lower()) return await self._get_instance(data['id']) def _add_inherit(name): def add(attr): attr.enum.append('INHERIT') return {'name': name, 'method': add} @accepts( Str('id', required=True), Patch( 'pool_dataset_create', 'pool_dataset_update', ('rm', { 'name': 'name' }), ('rm', { 'name': 'type' }), ('rm', { 'name': 'casesensitivity' }), # Its a readonly attribute ('rm', { 'name': 'sparse' }), # Create time only attribute ('rm', { 'name': 'volblocksize' }), # Create time only attribute ('edit', _add_inherit('atime')), ('edit', _add_inherit('exec')), ('edit', _add_inherit('sync')), ('edit', _add_inherit('compression')), ('edit', _add_inherit('deduplication')), ('edit', _add_inherit('readonly')), ('edit', _add_inherit('recordsize')), ('edit', _add_inherit('snapdir')), ('attr', { 'update': True }), )) async def do_update(self, id, data): """ Updates a dataset/zvol `id`. """ verrors = ValidationErrors() dataset = await self.middleware.call('pool.dataset.query', [('id', '=', id)]) if not dataset: verrors.add('id', f'{id} does not exist', errno.ENOENT) else: data['type'] = dataset[0]['type'] data['name'] = dataset[0]['name'] if data['type'] == 'VOLUME': data['volblocksize'] = dataset[0]['volblocksize']['value'] await self.__common_validation(verrors, 'pool_dataset_update', data, 'UPDATE') if verrors: raise verrors props = {} for i, real_name, transform, inheritable in ( ('atime', None, str.lower, True), ('comments', 'org.freenas:description', None, False), ('sync', None, str.lower, True), ('compression', None, str.lower, True), ('deduplication', 'dedup', str.lower, True), ('exec', None, str.lower, True), ('quota', None, _none, False), ('refquota', None, _none, False), ('reservation', None, _none, False), ('refreservation', None, _none, False), ('copies', None, None, False), ('snapdir', None, str.lower, True), ('readonly', None, str.lower, True), ('recordsize', None, None, True), ('volsize', None, lambda x: str(x), False), ): if i not in data: continue name = real_name or i if inheritable and data[i] == 'INHERIT': props[name] = {'source': 'INHERIT'} else: props[name] = { 'value': data[i] if not transform else transform(data[i]) } rv = await self.middleware.call('zfs.dataset.update', id, {'properties': props}) if data['type'] == 'FILESYSTEM' and 'share_type' in data: await self.middleware.call('notifier.change_dataset_share_type', id, data['share_type'].lower()) elif data['type'] == 'VOLUME' and 'volsize' in data: if await self.middleware.call('iscsi.extent.query', [('path', '=', f'zvol/{id}')]): await self.middleware.call('service.reload', 'iscsitarget') return rv async def __common_validation(self, verrors, schema, data, mode): assert mode in ('CREATE', 'UPDATE') parent = await self.middleware.call( 'zfs.dataset.query', [('id', '=', data['name'].rsplit('/')[0])]) if not parent: verrors.add( f'{schema}.name', 'Please specify a pool which exists for the dataset/volume to be created' ) else: parent = parent[0] if data['type'] == 'FILESYSTEM': for i in ('force_size', 'sparse', 'volsize', 'volblocksize'): if i in data: verrors.add(f'{schema}.{i}', 'This field is not valid for FILESYSTEM') elif data['type'] == 'VOLUME': if mode == 'CREATE' and 'volsize' not in data: verrors.add(f'{schema}.volsize', 'This field is required for VOLUME') for i in ( 'atime', 'casesensitivity', 'quota', 'refquota', 'recordsize', 'share_type', ): if i in data: verrors.add(f'{schema}.{i}', 'This field is not valid for VOLUME') if 'volsize' in data and parent: avail_mem = int(parent['properties']['available']['rawvalue']) if mode == 'UPDATE': avail_mem += int((await self.middleware.call( 'zfs.dataset.query', ['id', '=', data['name'] ]))[0]['properties']['used']['rawvalue']) if (data['volsize'] > (avail_mem * 0.80) and not data.get('force_size', False)): verrors.add( f'{schema}.volsize', 'It is not recommended to use more than 80% of your available space for VOLUME' ) if 'volblocksize' in data: if data['volblocksize'].isdigit(): block_size = int(data['volblocksize']) else: block_size = int(data['volblocksize'][:-1]) * 1024 if data['volsize'] % block_size: verrors.add( f'{schema}.volsize', 'Volume size should be a multiple of volume block size' ) @accepts(Str('id')) async def do_delete(self, id): return await self.middleware.call('zfs.dataset.delete', id) @item_method @accepts(Str('id')) async def promote(self, id): """ Promote the cloned dataset `id` """ dataset = await self.middleware.call('zfs.dataset.query', [('id', '=', id)]) if not dataset: raise CallError(f'Dataset "{id}" does not exist.', errno.ENOENT) if not dataset[0]['properties']['origin']['value']: raise CallError('Only cloned datasets can be promoted.', errno.EBADMSG) return await self.middleware.call('zfs.dataset.promote', id) @accepts(Str('id', default=None, required=True), Dict('pool_dataset_permission', Str('user'), Str('group'), UnixPerm('mode'), Str('acl', enum=['UNIX', 'MAC', 'WINDOWS'], default='UNIX'), Bool('recursive', default=False))) @item_method async def permission(self, id, data): path = (await self._get_instance(id))['mountpoint'] user = data.get('user', None) group = data.get('group', None) mode = data.get('mode', None) recursive = data.get('recursive', False) acl = data['acl'] verrors = ValidationErrors() if (acl == 'UNIX' or acl == 'MAC') and mode is None: verrors.add('pool_dataset_permission.mode', 'This field is required') if verrors: raise verrors await self.middleware.call('notifier.mp_change_permission', path, user, group, mode, recursive, acl.lower()) return data @accepts(Str('pool')) async def recommended_zvol_blocksize(self, pool): pool = await self.middleware.call('pool.query', [['name', '=', pool]], {'get': True}) numdisks = 4 for vdev in pool['topology']['data']: if vdev['type'] == 'RAIDZ': num = len(vdev['children']) - 1 elif vdev['type'] == 'RAIDZ2': num = len(vdev['children']) - 2 elif vdev['type'] == 'RAIDZ3': num = len(vdev['children']) - 3 elif vdev['type'] == 'MIRROR': num = 1 else: num = len(vdev['children']) if num > numdisks: numdisks = num return '%dK' % 2**((numdisks * 4) - 1).bit_length()
class ACLBase(ServicePartBase): @accepts( Dict( 'filesystem_acl', Str('path', required=True), Int('uid', null=True, default=None), Int('gid', null=True, default=None), List( 'dacl', items=[ Dict( 'aclentry', Str('tag', enum=[ 'owner@', 'group@', 'everyone@', 'USER', 'GROUP' ]), Int('id', null=True), Str('type', enum=['ALLOW', 'DENY']), Dict( 'perms', Bool('READ_DATA'), Bool('WRITE_DATA'), Bool('APPEND_DATA'), Bool('READ_NAMED_ATTRS'), Bool('WRITE_NAMED_ATTRS'), Bool('EXECUTE'), Bool('DELETE_CHILD'), Bool('READ_ATTRIBUTES'), Bool('WRITE_ATTRIBUTES'), Bool('DELETE'), Bool('READ_ACL'), Bool('WRITE_ACL'), Bool('WRITE_OWNER'), Bool('SYNCHRONIZE'), Str('BASIC', enum=[ 'FULL_CONTROL', 'MODIFY', 'READ', 'TRAVERSE' ]), ), Dict( 'flags', Bool('FILE_INHERIT'), Bool('DIRECTORY_INHERIT'), Bool('NO_PROPAGATE_INHERIT'), Bool('INHERIT_ONLY'), Bool('INHERITED'), Str('BASIC', enum=['INHERIT', 'NOINHERIT']), ), ), Dict( 'posix1e_ace', Bool('default', default=False), Str('tag', enum=[ 'USER_OBJ', 'GROUP_OBJ', 'USER', 'GROUP', 'OTHER', 'MASK' ]), Int('id', default=-1), Dict( 'perms', Bool('READ', default=False), Bool('WRITE', default=False), Bool('EXECUTE', default=False), ), ) ], ), Dict( 'nfs41_flags', Bool('autoinherit', default=False), Bool('protected', default=False), ), Str('acltype', enum=[x.name for x in ACLType], null=True), Dict('options', Bool('stripacl', default=False), Bool('recursive', default=False), Bool('traverse', default=False), Bool('canonicalize', default=True)))) @job(lock="perm_change") def setacl(self, job, data): """ Set ACL of a given path. Takes the following parameters: `path` full path to directory or file. Paths on clustered volumes may be specifed with the path prefix `CLUSTER:<volume name>`. For example, to list directories in the directory 'data' in the clustered volume `smb01`, the path should be specified as `CLUSTER:smb01/data`. `dacl` ACL entries. Formatting depends on the underlying `acltype`. NFS4ACL requires NFSv4 entries. POSIX1e requires POSIX1e entries. `uid` the desired UID of the file user. If set to None (the default), then user is not changed. `gid` the desired GID of the file group. If set to None (the default), then group is not changed. `recursive` apply the ACL recursively `traverse` traverse filestem boundaries (ZFS datasets) `strip` convert ACL to trivial. ACL is trivial if it can be expressed as a file mode without losing any access rules. `canonicalize` reorder ACL entries so that they are in concanical form as described in the Microsoft documentation MS-DTYP 2.4.5 (ACL). This only applies to NFSv4 ACLs. For case of NFSv4 ACLs USER_OBJ, GROUP_OBJ, and EVERYONE with owner@, group@, everyone@ for consistency with getfacl and setfacl. If one of aforementioned special tags is used, 'id' must be set to None. An inheriting empty everyone@ ACE is appended to non-trivial ACLs in order to enforce Windows expectations regarding permissions inheritance. This entry is removed from NT ACL returned to SMB clients when 'ixnas' samba VFS module is enabled. """ @accepts( Str('path'), Bool('simplified', default=True), Bool('resolve_ids', default=False), ) def getacl(self, path, simplified, resolve_ids): """ Return ACL of a given path. This may return a POSIX1e ACL or a NFSv4 ACL. The acl type is indicated by the `acltype` key. `simplified` - effect of this depends on ACL type on underlying filesystem. In the case of NFSv4 ACLs simplified permissions and flags are returned for ACL entries where applicable. NFSv4 errata below. In the case of POSIX1E ACls, this setting has no impact on returned ACL. `resolve_ids` - adds additional `who` key to each ACL entry, that converts the numeric id to a user name or group name. In the case of owner@ and group@ (NFSv4) or USER_OBJ and GROUP_OBJ (POSIX1E), st_uid or st_gid will be converted from stat() return for file. In the case of MASK (POSIX1E), OTHER (POSIX1E), everyone@ (NFSv4), key `who` will be included, but set to null. In case of failure to resolve the id to a name, `who` will be set to null. This option should only be used if resolving ids to names is required. Errata about ACLType NFSv4: `simplified` returns a shortened form of the ACL permset and flags where applicable. If permissions have been simplified, then the `perms` object will contain only a single `BASIC` key with a string describing the underlying permissions set. `TRAVERSE` sufficient rights to traverse a directory, but not read contents. `READ` sufficient rights to traverse a directory, and read file contents. `MODIFIY` sufficient rights to traverse, read, write, and modify a file. `FULL_CONTROL` all permissions. If the permisssions do not fit within one of the pre-defined simplified permissions types, then the full ACL entry will be returned. """ @accepts( Dict( 'filesystem_ownership', Str('path', required=True), Int('uid', null=True, default=None), Int('gid', null=True, default=None), Dict('options', Bool('recursive', default=False), Bool('traverse', default=False)))) @job(lock="perm_change") def chown(self, job, data): """ Change owner or group of file at `path`. `uid` and `gid` specify new owner of the file. If either key is absent or None, then existing value on the file is not changed. `recursive` performs action recursively, but does not traverse filesystem mount points. If `traverse` and `recursive` are specified, then the chown operation will traverse filesystem mount points. """ @accepts( Dict( 'filesystem_permission', Str('path', required=True), UnixPerm('mode', null=True), Int('uid', null=True, default=None), Int('gid', null=True, default=None), Dict( 'options', Bool('stripacl', default=False), Bool('recursive', default=False), Bool('traverse', default=False), ))) @job(lock="perm_change") def setperm(self, job, data): """ Set unix permissions on given `path`. Paths on clustered volumes may be specifed with the path prefix `CLUSTER:<volume name>`. For example, to list directories in the directory 'data' in the clustered volume `smb01`, the path should be specified as `CLUSTER:smb01/data`. If `mode` is specified then the mode will be applied to the path and files and subdirectories depending on which `options` are selected. Mode should be formatted as string representation of octal permissions bits. `uid` the desired UID of the file user. If set to None (the default), then user is not changed. `gid` the desired GID of the file group. If set to None (the default), then group is not changed. `stripacl` setperm will fail if an extended ACL is present on `path`, unless `stripacl` is set to True. `recursive` remove ACLs recursively, but do not traverse dataset boundaries. `traverse` remove ACLs from child datasets. If no `mode` is set, and `stripacl` is True, then non-trivial ACLs will be converted to trivial ACLs. An ACL is trivial if it can be expressed as a file mode without losing any access rules. """ @accepts() async def default_acl_choices(self): """ Get list of default ACL types. """ @accepts( Str('acl_type', default='OPEN', enum=ACLDefault.options()), Str('share_type', default='NONE', enum=['NONE', 'SMB', 'NFS']), ) async def get_default_acl(self, acl_type, share_type): """