class DiskService(Service): @accepts(Ref('query-filters'), Ref('query-options')) def query(self, filters=None, options=None): if filters is None: filters = [] if options is None: options = {} filters.append(('disk_enabled', '=', True)) options['extend'] = 'disk.disk_extend' return self.middleware.call('datastore.query', 'storage.disk', filters, options) @private def disk_extend(self, disk): """ This is a compatiblity method to remove superfluous "disk_" suffix from attributes from the Django datastore """ for k, v in disk.items(): if k.startswith('disk_'): del disk[k] disk[k[5:]] = v # enabled is an internal attribute that does not need to be exposed disk.pop('enabled', None) return disk
class KubernetesCronJobService(CRUDService): class Config: namespace = 'k8s.cronjob' private = True @filterable async def query(self, filters, options): async with api_client() as (api, context): return filter_list([ d.to_dict() for d in (await context['cronjob_batch_api']. list_cron_job_for_all_namespaces()).items ], filters, options) @accepts(Ref('k8s_job_create')) async def do_create(self, data): async with api_client() as (api, context): try: await context['cronjob_batch_api'].create_namespaced_cron_job( namespace=data['namespace'], body=data['body']) except client.exceptions.ApiException as e: raise CallError(f'Unable to create job: {e}') else: return await self.query([ ['metadata.name', '=', data['metadata.name']], ['metadata.namespace', '=', data['namespace']], ], {'get': True}) @accepts( Str('name'), Ref('k8s_job_create'), ) async def do_update(self, name, data): async with api_client() as (api, context): try: await context['cronjob_batch_api'].patch_namespaced_cron_job( name, namespace=data['namespace'], body=data['body']) except client.exceptions.ApiException as e: raise CallError(f'Unable to patch {name} job: {e}') else: return await self.query([ ['metadata.name', '=', name], ['metadata.namespace', '=', data['namespace']], ], {'get': True}) @accepts(Str('name'), Dict( 'k8s_job_delete_options', Str('namespace', required=True), )) async def do_delete(self, name, options): async with api_client() as (api, context): try: await context['cronjob_batch_api'].delete_namespaced_cron_job( name, options['namespace']) except client.exceptions.ApiException as e: raise CallError(f'Unable to delete job: {e}') else: return True
class CertificateauthorityService(CRUDService): @accepts(Ref('query-filters'), Ref('query-options')) def query(self, filters=None, options=None): if options is None: options = {} options['extend'] = 'certificate.cert_extend' return self.middleware.call('datastore.query', 'system.certificateauthority', filters, options)
class SMBService(Service): class Config: service = 'cifs' service_verb = 'restart' @accepts( Str('info_level', enum=[x.name for x in InfoLevel], default=InfoLevel.ALL.name), Ref('query-filters'), Ref('query-options'), Dict('status_options', Bool('verbose', default=True), Bool('fast', default=False), Str('restrict_user', default='') ) ) async def status(self, info_level, filters, options, status_options): """ Returns SMB server status (sessions, open files, locks, notifications). `info_level` type of information requests. Defaults to ALL. `status_options` additional options to filter query results. Supported values are as follows: `verbose` gives more verbose status output `fast` causes smbstatus to not check if the status data is valid by checking if the processes that the status data refer to all still exist. This speeds up execution on busy systems and clusters but might display stale data of processes that died without cleaning up properly. `restrict_user` specifies the limits results to the specified user. """ flags = '-j' flags = flags + InfoLevel[info_level].value flags = flags + 'v' if status_options['verbose'] else flags flags = flags + 'f' if status_options['fast'] else flags statuscmd = [SMBCmd.STATUS.value, '-d' '0', flags] if status_options['restrict_user']: statuscmd.extend(['-U', status_options['restrict_user']]) smbstatus = await run(statuscmd, check=False) if smbstatus.returncode != 0: self.logger.debug('smbstatus [{%s}] failed with error: ({%s})', flags, smbstatus.stderr.decode().strip()) return filter_list(json.loads(smbstatus.stdout.decode()), filters, options)
class RsyncModService(CRUDService): class Config: datastore = 'services.rsyncmod' datastore_prefix = 'rsyncmod_' @accepts( Dict( 'rsyncmod', Str('name', validators=[Match(r'[^/\]]')]), Str('comment'), Str('path'), Str('mode'), Int('maxconn'), Str('user'), Str('group'), List('hostsallow', items=[Str('hostsallow')]), List('hostsdeny', items=[Str('hostdeny')]), Str('auxiliary'), register=True, )) async def do_create(self, data): if data.get("hostsallow"): data["hostsallow"] = " ".join(data["hostsallow"]) else: data["hostsallow"] = '' if data.get("hostsdeny"): data["hostsdeny"] = " ".join(data["hostsdeny"]) else: data["hostsdeny"] = '' data['id'] = await self.middleware.call( 'datastore.insert', self._config.datastore, data, {'prefix': self._config.datastore_prefix}) await self.middleware.call('service.reload', 'rsync') return data @accepts(Int('id'), Ref('rsyncmod')) async def do_update(self, id, data): module = await self.middleware.call( 'datastore.query', self._config.datastore, [('id', '=', id)], { 'prefix': self._config.datastore_prefix, 'get': True }) module.update(data) module["hostsallow"] = " ".join(module["hostsallow"]) module["hostsdeny"] = " ".join(module["hostsdeny"]) await self.middleware.call('datastore.update', self._config.datastore, id, data, {'prefix': self._config.datastore_prefix}) await self.middleware.call('service.reload', 'rsync') return module @accepts(Int('id')) async def do_delete(self, id): return await self.middleware.call('datastore.delete', self._config.datastore, id)
class CredentialsService(CRUDService): class Config: namespace = "cloudsync.credentials" datastore = "system.cloudcredentials" @accepts( Dict( "cloud_sync_credentials", Str("name"), Str("provider"), Dict("attributes", additional_attrs=True), register=True, )) async def do_create(self, data): self._validate("cloud_sync_credentials", data) data["id"] = await self.middleware.call( "datastore.insert", "system.cloudcredentials", data, ) return data @accepts(Int("id"), Ref("cloud_sync_credentials")) async def do_update(self, id, data): self._validate("cloud_sync_credentials", data) await self.middleware.call( "datastore.update", "system.cloudcredentials", id, data, ) return data @accepts(Int("id")) async def do_delete(self, id): await self.middleware.call( "datastore.delete", "system.cloudcredentials", id, ) def _validate(self, schema_name, data): verrors = ValidationErrors() if data["provider"] not in REMOTES: verrors.add(f"{schema_name}.provider", "Invalid provider") else: provider = REMOTES[data["provider"]] attributes_verrors = validate_attributes( provider.credentials_schema, data) verrors.add_child(f"{schema_name}.attributes", attributes_verrors) if verrors: raise verrors
class KubernetesStatefulsetService(CRUDService): class Config: namespace = 'k8s.statefulset' private = True @filterable async def query(self, filters, options): async with api_client() as (api, context): stateful_sets = [ d.to_dict() for d in (await context['apps_api']. list_stateful_set_for_all_namespaces()).items ] events = await self.middleware.call( 'kubernetes.get_events_of_resource_type', 'StatefulSet', [s['metadata']['uid'] for s in stateful_sets]) for stateful_set in stateful_sets: stateful_set['events'] = events[stateful_set['metadata'] ['uid']] return filter_list(stateful_sets, filters, options) @accepts( Dict('statefulset_create', Str('namespace', required=True), Dict('body', additional_attrs=True, required=True), register=True)) async def do_create(self, data): async with api_client() as (api, context): try: await context['apps_api'].create_namespaced_stateful_set( namespace=data['namespace'], body=data['body']) except client.exceptions.ApiException as e: raise CallError(f'Unable to create statefulset: {e}') @accepts( Str('name'), Ref('statefulset_create'), ) async def do_update(self, name, data): async with api_client() as (api, context): try: await context['apps_api'].patch_namespaced_stateful_set( name, namespace=data['namespace'], body=data['body']) except client.exceptions.ApiException as e: raise CallError(f'Unable to patch {name} statefulset: {e}') @accepts(Str('name'), Dict( 'statefulset_delete_options', Str('namespace', required=True), )) async def do_delete(self, name, options): async with api_client() as (api, context): try: await context['apps_api'].delete_namespaced_stateful_set( name, options['namespace']) except client.exceptions.ApiException as e: raise CallError(f'Unable to delete statefulset: {e}')
class DiskService(Service): @accepts(Ref('query-filters'), Ref('query-options')) def query(self, filters=None, options=None): if filters is None: filters = [] if options is None: options = {} options['suffix'] = 'disk_' filters.append(('enabled', '=', True)) options['extend'] = 'disk.disk_extend' return self.middleware.call('datastore.query', 'storage.disk', filters, options) @private def disk_extend(self, disk): disk.pop('enabled', None) return disk
class BackupCredentialService(CRUDService): class Config: namespace = 'backup.credential' @accepts(Ref('query-filters'), Ref('query-options')) def query(self, filters=None, options=None): return self.middleware.call('datastore.query', 'system.cloudcredentials', filters, options) @accepts( Dict( 'backup-credential', Str('name'), Str('provider', enum=[ 'AMAZON', ]), Dict('attributes', additional_attrs=True), register=True, )) def do_create(self, data): return self.middleware.call( 'datastore.insert', 'system.cloudcredentials', data, ) @accepts(Int('id'), Ref('backup-credential')) def do_update(self, id, data): return self.middleware.call( 'datastore.update', 'system.cloudcredentials', id, data, ) @accepts(Int('id')) def do_delete(self, id): return self.middleware.call( 'datastore.delete', 'system.cloudcredentials', id, )
class KubernetesDaemonsetService(CRUDService): class Config: namespace = 'k8s.daemonset' private = True @filterable async def query(self, filters, options): async with api_client() as (api, context): return filter_list( [d.to_dict() for d in (await context['apps_api'].list_daemon_set_for_all_namespaces()).items], filters, options ) @accepts( Dict( 'daemonset_create', Str('namespace', required=True), Dict('body', additional_attrs=True, required=True), register=True ) ) async def do_create(self, data): async with api_client() as (api, context): try: await context['apps_api'].create_namespaced_daemon_set(namespace=data['namespace'], body=data['body']) except client.exceptions.ApiException as e: raise CallError(f'Unable to create daemonset: {e}') @accepts( Str('name'), Ref('daemonset_create'), ) async def do_update(self, name, data): async with api_client() as (api, context): try: await context['apps_api'].patch_namespaced_daemon_set( name, namespace=data['namespace'], body=data['body'] ) except client.exceptions.ApiException as e: raise CallError(f'Unable to patch {name} daemonset: {e}') @accepts( Str('name'), Dict( 'daemonset_delete_options', Str('namespace', required=True), ) ) async def do_delete(self, name, options): async with api_client() as (api, context): try: await context['apps_api'].delete_namespaced_daemon_set(name, options['namespace']) except client.exceptions.ApiException as e: raise CallError(f'Unable to delete daemonset: {e}')
class KubernetesSecretService(CRUDService): class Config: namespace = 'k8s.secret' private = True @filterable async def query(self, filters=None, options=None): options = options or {} label_selector = options.get('extra', {}).get('label_selector') kwargs = {k: v for k, v in [('label_selector', label_selector)] if v} async with api_client() as (api, context): return filter_list([ d.to_dict() for d in ( await context['core_api'].list_secret_for_all_namespaces( **kwargs)).items ], filters, options) @accepts( Dict('secret_create', Str('namespace', required=True), Dict('body', additional_attrs=True, required=True), register=True)) async def do_create(self, data): async with api_client() as (api, context): try: await context['core_api'].create_namespaced_secret( namespace=data['namespace'], body=data['body']) except client.exceptions.ApiException as e: raise CallError(f'Unable to create secret: {e}') @accepts( Str('name'), Ref('secret_create'), ) async def do_update(self, name, data): async with api_client() as (api, context): try: await context['core_api'].patch_namespaced_secret( name, namespace=data['namespace'], body=data['body']) except client.exceptions.ApiException as e: raise CallError(f'Unable to patch {name} secret: {e}') @accepts(Str('name'), Dict( 'secret_delete_options', Str('namespace', required=True), )) async def do_delete(self, name, options): async with api_client() as (api, context): try: await context['core_api'].delete_namespaced_secret( name, options['namespace']) except client.exceptions.ApiException as e: raise CallError(f'Unable to delete secret: {e}')
class LLDPService(SystemServiceService): class Config: service = 'lldp' datastore_prefix = 'lldp_' cli_namespace = 'service.lldp' ENTRY = Dict( 'lldp_entry', Bool('intdesc', required=True), Str('country', max_length=2, required=True), Str('location', required=True), Int('id', required=True), ) @accepts() @returns(Ref('country_choices')) async def country_choices(self): """ Returns country choices for LLDP. """ return await self.middleware.call('system.general.country_choices') async def do_update(self, data): """ Update LLDP Service Configuration. `country` is a two letter ISO 3166 country code required for LLDP location support. `location` is an optional attribute specifying the physical location of the host. """ old = await self.config() new = old.copy() new.update(data) verrors = ValidationErrors() if new['country'] not in await self.country_choices(): verrors.add( 'lldp_update.country', f'{new["country"]} not in countries recognized by the system.') verrors.check() await self._update_service(old, new) return await self.config()
class ChartReleaseService(Service): class Config: namespace = 'chart.release' @accepts(Str('release_name')) @returns(Ref('chart_release_entry')) @job(lock=lambda args: f'chart_release_redeploy_{args[0]}') async def redeploy(self, job, release_name): """ Redeploy will initiate a new rollout of the Helm chart according to upgrade strategy defined by the chart release workloads. A good example for redeploying is updating kubernetes pods with an updated container image. """ release = await self.middleware.call('chart.release.get_instance', release_name) chart_path = os.path.join(release['path'], 'charts', release['chart_metadata']['version']) if not os.path.exists(chart_path): raise CallError( f'Unable to locate {chart_path!r} chart version for redeploying {release!r} chart release', errno=errno.ENOENT) config = await add_context_to_configuration( release['config'], { CONTEXT_KEY_NAME: { **get_action_context(release_name), 'operation': 'UPDATE', 'isUpdate': True, } }, self.middleware) await self.middleware.call('chart.release.helm_action', release_name, chart_path, config, 'update') job.set_progress(90, 'Syncing secrets for chart release') await self.middleware.call('chart.release.sync_secrets_for_release', release_name) await self.middleware.call('chart.release.refresh_events_state', release_name) job.set_progress( 100, f'Successfully redeployed {release_name!r} chart release') return await self.middleware.call('chart.release.get_instance', release_name)
class CryptoKeyService(Service): class Config: private = True @accepts(Ref('cert_extensions'), Str('schema')) def validate_extensions(self, extensions_data, schema): # We do not need to validate some extensions like `AuthorityKeyIdentifier`. # They are generated from the cert/ca's public key contents. So we skip these. skip_extension = ['AuthorityKeyIdentifier'] verrors = ValidationErrors() for extension in filter( lambda v: v[1]['enabled'] and v[0] not in skip_extension, extensions_data.items()): klass = getattr(x509.extensions, extension[0]) try: klass(*get_extension_params(extension)) except Exception as e: verrors.add( f'{schema}.{extension[0]}', f'Please provide valid values for {extension[0]}: {e}') if extensions_data['KeyUsage']['enabled'] and extensions_data[ 'KeyUsage']['key_cert_sign']: if not extensions_data['BasicConstraints'][ 'enabled'] or not extensions_data['BasicConstraints']['ca']: verrors.add( f'{schema}.BasicConstraints', 'Please enable ca when key_cert_sign is set in KeyUsage as per RFC 5280.' ) if extensions_data['ExtendedKeyUsage'][ 'enabled'] and not extensions_data['ExtendedKeyUsage'][ 'usages']: verrors.add( f'{schema}.ExtendedKeyUsage.usages', 'Please specify at least one USAGE for this extension.') return verrors
class ServiceService(CRUDService): SERVICE_DEFS = { 's3': ServiceDefinition('minio', '/var/run/minio.pid'), 'ssh': ServiceDefinition('sshd', '/var/run/sshd.pid'), 'rsync': ServiceDefinition('rsync', '/var/run/rsyncd.pid'), 'nfs': ServiceDefinition('nfsd', None), 'afp': ServiceDefinition('netatalk', None), 'cifs': ServiceDefinition('smbd', '/var/run/samba4/smbd.pid'), 'dynamicdns': ServiceDefinition('inadyn', None), 'snmp': ServiceDefinition('snmpd', '/var/run/net_snmpd.pid'), 'ftp': ServiceDefinition('proftpd', '/var/run/proftpd.pid'), 'tftp': ServiceDefinition('inetd', '/var/run/inetd.pid'), 'iscsitarget': ServiceDefinition('ctld', '/var/run/ctld.pid'), 'lldp': ServiceDefinition('ladvd', '/var/run/ladvd.pid'), 'ups': ServiceDefinition('upsd', '/var/db/nut/upsd.pid'), 'upsmon': ServiceDefinition('upsmon', '/var/db/nut/upsmon.pid'), 'smartd': ServiceDefinition('smartd', 'smartd-daemon', '/var/run/smartd-daemon.pid'), 'webshell': ServiceDefinition(None, '/var/run/webshell.pid'), 'webdav': ServiceDefinition('httpd', '/var/run/httpd.pid'), 'netdata': ServiceDefinition('netdata', '/var/db/netdata/netdata.pid'), 'asigra': ServiceDefinition('asigra', '/var/run/dssystem.pid') } @filterable async def query(self, filters=None, options=None): """ Query all system services with `query-filters` and `query-options`. """ if options is None: options = {} options['prefix'] = 'srv_' services = await self.middleware.call('datastore.query', 'services.services', filters, options) # In case a single service has been requested if not isinstance(services, list): services = [services] jobs = { asyncio.ensure_future(self._get_status(entry)): entry for entry in services } if jobs: done, pending = await asyncio.wait(list(jobs.keys()), timeout=15) def result(task): """ Method to handle results of the coroutines. In case of error or timeout, provide UNKNOWN state. """ result = None try: if task in done: result = task.result() except Exception: pass if result is None: entry = jobs.get(task) self.logger.warn('Failed to get status for %s', entry['service']) entry['state'] = 'UNKNOWN' entry['pids'] = [] return entry else: return result services = list(map(result, jobs)) return filter_list(services, filters, options) @accepts( Str('id_or_name'), Dict( 'service-update', Bool('enable', default=False), ), ) async def do_update(self, id_or_name, data): """ Update service entry of `id_or_name`. Currently it only accepts `enable` option which means whether the service should start on boot. """ if not id_or_name.isdigit(): svc = await self.middleware.call( 'datastore.query', 'services.services', [('srv_service', '=', id_or_name)]) if not svc: raise CallError(f'Service {id_or_name} not found.', errno.ENOENT) id_or_name = svc[0]['id'] rv = await self.middleware.call('datastore.update', 'services.services', id_or_name, {'srv_enable': data['enable']}) await self.middleware.call('etc.generate', 'rc') return rv @accepts( Str('service'), Dict( 'service-control', Bool('onetime', default=True), Bool('wait', default=None, null=True), Bool('sync', default=None, null=True), register=True, ), ) async def start(self, service, options=None): """ Start the service specified by `service`. The helper will use method self._start_[service]() to start the service. If the method does not exist, it would fallback using service(8).""" await self.middleware.call_hook('service.pre_action', service, 'start', options) sn = self._started_notify("start", service) await self._simplecmd("start", service, options) return await self.started(service, sn) async def started(self, service, sn=None): """ Test if service specified by `service` has been started. """ if sn: await self.middleware.run_in_thread(sn.join) try: svc = await self.query([('service', '=', service)], {'get': True}) self.middleware.send_event('service.query', 'CHANGED', fields=svc) return svc['state'] == 'RUNNING' except IndexError: f = getattr(self, '_started_' + service, None) if callable(f): if inspect.iscoroutinefunction(f): return (await f())[0] else: return f()[0] else: return (await self._started(service))[0] @accepts( Str('service'), Ref('service-control'), ) async def stop(self, service, options=None): """ Stop the service specified by `service`. The helper will use method self._stop_[service]() to stop the service. If the method does not exist, it would fallback using service(8).""" await self.middleware.call_hook('service.pre_action', service, 'stop', options) sn = self._started_notify("stop", service) await self._simplecmd("stop", service, options) return await self.started(service, sn) @accepts( Str('service'), Ref('service-control'), ) async def restart(self, service, options=None): """ Restart the service specified by `service`. The helper will use method self._restart_[service]() to restart the service. If the method does not exist, it would fallback using service(8).""" await self.middleware.call_hook('service.pre_action', service, 'restart', options) sn = self._started_notify("restart", service) await self._simplecmd("restart", service, options) return await self.started(service, sn) @accepts( Str('service'), Ref('service-control'), ) async def reload(self, service, options=None): """ Reload the service specified by `service`. The helper will use method self._reload_[service]() to reload the service. If the method does not exist, the helper will try self.restart of the service instead.""" await self.middleware.call_hook('service.pre_action', service, 'reload', options) try: await self._simplecmd("reload", service, options) except Exception as e: await self.restart(service, options) return await self.started(service) async def _get_status(self, service): f = getattr(self, '_started_' + service['service'], None) if callable(f): if inspect.iscoroutinefunction(f): running, pids = await f() else: running, pids = f() else: running, pids = await self._started(service['service']) if running: state = 'RUNNING' else: state = 'STOPPED' service['state'] = state service['pids'] = pids return service async def _simplecmd(self, action, what, options=None): self.logger.debug("Calling: %s(%s) ", action, what) f = getattr(self, '_' + action + '_' + what, None) if f is None: # Provide generic start/stop/restart verbs for rc.d scripts if what in self.SERVICE_DEFS: if self.SERVICE_DEFS[what].rc_script: what = self.SERVICE_DEFS[what].rc_script if action in ("start", "stop", "restart", "reload"): if action == 'restart': await self._system("/usr/sbin/service " + what + " forcestop ") await self._service(what, action, **options) else: raise ValueError("Internal error: Unknown command") else: call = f(**(options or {})) if inspect.iscoroutinefunction(f): await call async def _system(self, cmd): proc = await Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True) stdout = (await proc.communicate())[0] if proc.returncode != 0: self.logger.warning("Command %r failed with code %d:\n%s", cmd, proc.returncode, stdout) return proc.returncode async def _service(self, service, verb, **options): onetime = options.pop('onetime', None) force = options.pop('force', None) quiet = options.pop('quiet', None) extra = options.pop('extra', '') # force comes before one which comes before quiet # they are mutually exclusive preverb = '' if force: preverb = 'force' elif onetime: preverb = 'one' elif quiet: preverb = 'quiet' return await self._system('/usr/sbin/service {} {}{} {}'.format( service, preverb, verb, extra, )) def _started_notify(self, verb, what): """ The check for started [or not] processes is currently done in 2 steps This is the first step which involves a thread StartNotify that watch for event before actually start/stop rc.d scripts Returns: StartNotify object if the service is known or None otherwise """ if what in self.SERVICE_DEFS: sn = StartNotify(verb=verb, pidfile=self.SERVICE_DEFS[what].pidfile) sn.start() return sn else: return None async def _started(self, what, notify=None): """ This is the second step:: Wait for the StartNotify thread to finish and then check for the status of pidfile/procname using pgrep Returns: True whether the service is alive, False otherwise """ if what in self.SERVICE_DEFS: if notify: await self.middleware.run_in_thread(notify.join) if self.SERVICE_DEFS[what].pidfile: pgrep = "/bin/pgrep -F {}{}".format( self.SERVICE_DEFS[what].pidfile, ' ' + self.SERVICE_DEFS[what].procname if self.SERVICE_DEFS[what].procname else '', ) else: pgrep = "/bin/pgrep {}".format( self.SERVICE_DEFS[what].procname) proc = await Popen(pgrep, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True) data = (await proc.communicate())[0].decode() if proc.returncode == 0: return True, [ int(i) for i in data.strip().split('\n') if i.isdigit() ] return False, [] async def _start_asigra(self, **kwargs): await self.middleware.call('asigra.setup_filesystems') await self.middleware.call('asigra.setup_postgresql') await self._service("postgresql", "start", force=True, **kwargs) await self.middleware.call('asigra.setup_asigra') await self.middleware.call('etc.generate', 'asigra') await self._service("dssystem", "start", force=True, **kwargs) async def _stop_asigra(self, **kwargs): await self._service("dssystem", "stop", force=True, **kwargs) await self._service("postgresql", "stop", force=True, **kwargs) async def _restart_asigra(self, **kwargs): await self._stop_asigra(**kwargs) await self._start_asigra(**kwargs) async def _started_asigra(self, **kwargs): if await self._service("dssystem", "status", force=True, ** kwargs) != 0: return False, [] return True, [] async def _start_webdav(self, **kwargs): await self.middleware.call('etc.generate', 'webdav') await self._service("apache24", "start", **kwargs) async def _stop_webdav(self, **kwargs): await self._service("apache24", "stop", **kwargs) async def _restart_webdav(self, **kwargs): await self._service("apache24", "stop", force=True, **kwargs) await self.middleware.call('etc.generate', 'webdav') await self._service("apache24", "restart", **kwargs) async def _reload_webdav(self, **kwargs): await self.middleware.call('etc.generate', 'webdav') await self._service("apache24", "reload", **kwargs) async def _restart_django(self, **kwargs): await self._service("django", "restart", **kwargs) async def _start_webshell(self, **kwargs): await self._system( "/usr/local/bin/python /usr/local/www/freenasUI/tools/webshell.py") async def _restart_webshell(self, **kwargs): try: with open('/var/run/webshell.pid', 'r') as f: pid = f.read() os.kill(int(pid), signal.SIGTERM) time.sleep(0.2) os.kill(int(pid), signal.SIGKILL) except Exception: pass await self._system( "ulimit -n 1024 && /usr/local/bin/python /usr/local/www/freenasUI/tools/webshell.py" ) async def _restart_iscsitarget(self, **kwargs): await self.middleware.call("etc.generate", "ctld") await self._service("ctld", "stop", force=True, **kwargs) await self.middleware.call("etc.generate", "ctld") await self._service("ctld", "restart", **kwargs) async def _start_iscsitarget(self, **kwargs): await self.middleware.call("etc.generate", "ctld") await self._service("ctld", "start", **kwargs) async def _stop_iscsitarget(self, **kwargs): with contextlib.suppress(IndexError): sysctl.filter("kern.cam.ctl.ha_peer")[0].value = "" await self._service("ctld", "stop", force=True, **kwargs) async def _reload_iscsitarget(self, **kwargs): await self.middleware.call("etc.generate", "ctld") await self._service("ctld", "reload", **kwargs) async def _start_collectd(self, **kwargs): if not await self.started('rrdcached'): # Let's ensure that before we start collectd, rrdcached is always running await self.start('rrdcached') await self.middleware.call('etc.generate', 'collectd') await self._service("collectd-daemon", "restart", **kwargs) async def _stop_collectd(self, **kwargs): await self._service("collectd-daemon", "stop", **kwargs) async def _restart_collectd(self, **kwargs): await self._stop_collectd(**kwargs) await self._start_collectd(**kwargs) async def _started_collectd(self, **kwargs): if await self._service('collectd-daemon', 'status', quiet=True, **kwargs): return False, [] else: return True, [] async def _started_rrdcached(self, **kwargs): if await self._service('rrdcached', 'status', quiet=True, **kwargs): return False, [] else: return True, [] async def _stop_rrdcached(self, **kwargs): await self.stop('collectd') await self._service('rrdcached', 'stop', **kwargs) async def _restart_rrdcached(self, **kwargs): await self._stop_rrdcached(**kwargs) await self.start('rrdcached') await self.start('collectd') async def _reload_rc(self, **kwargs): await self.middleware.call('etc.generate', 'rc') async def _restart_powerd(self, **kwargs): await self.middleware.call('etc.generate', 'rc') await self._service('powerd', 'restart', **kwargs) async def _reload_sysctl(self, **kwargs): await self.middleware.call('etc.generate', 'sysctl') async def _start_network(self, **kwargs): await self.middleware.call('interface.sync') await self.middleware.call('route.sync') async def _reload_named(self, **kwargs): await self._service("named", "reload", **kwargs) async def _restart_syscons(self, **kwargs): await self.middleware.call('etc.generate', 'rc') await self._service('syscons', 'restart', **kwargs) async def _reload_hostname(self, **kwargs): await self._system('/bin/hostname ""') await self.middleware.call('etc.generate', 'hostname') await self.middleware.call('etc.generate', 'rc') await self._service("hostname", "start", quiet=True, **kwargs) await self._service("mdnsd", "restart", quiet=True, **kwargs) await self._restart_collectd(**kwargs) async def _reload_resolvconf(self, **kwargs): await self._reload_hostname() await self.middleware.call('dns.sync') async def _reload_networkgeneral(self, **kwargs): await self._reload_resolvconf() await self._service("routing", "restart", **kwargs) async def _start_routing(self, **kwargs): await self.middleware.call('etc.generate', 'rc') await self._service('routing', 'start', **kwargs) async def _reload_timeservices(self, **kwargs): await self.middleware.call('etc.generate', 'localtime') await self.middleware.call('etc.generate', 'ntpd') await self._service("ntpd", "restart", **kwargs) settings = await self.middleware.call('datastore.query', 'system.settings', [], { 'order_by': ['-id'], 'get': True }) os.environ['TZ'] = settings['stg_timezone'] time.tzset() async def _restart_ntpd(self, **kwargs): await self.middleware.call('etc.generate', 'ntpd') await self._service('ntpd', 'restart', **kwargs) async def _start_smartd(self, **kwargs): await self.middleware.call("etc.generate", "smartd") await self._service("smartd-daemon", "start", **kwargs) def _initializing_smartd_pid(self): """ smartd initialization can take a long time if lots of disks are present It only writes pidfile at the end of the initialization but forks immediately This method returns PID of smartd process that is still initializing and has not written pidfile yet """ if os.path.exists(self.SERVICE_DEFS["smartd"].pidfile): # Already started, no need for special handling return for process in psutil.process_iter(attrs=["cmdline", "create_time"]): if process.info["cmdline"][:1] == ["/usr/local/sbin/smartd"]: break else: # No smartd process present return lifetime = time.time() - process.info["create_time"] if lifetime < 300: # Looks like just the process we need return process.pid self.logger.warning( "Got an orphan smartd process: pid=%r, lifetime=%r", process.pid, lifetime) async def _started_smartd(self, **kwargs): result = await self._started("smartd") if result[0]: return result if await self.middleware.run_in_thread(self._initializing_smartd_pid ) is not None: return True, [] return False, [] async def _reload_smartd(self, **kwargs): await self.middleware.call("etc.generate", "smartd") pid = await self.middleware.run_in_thread(self._initializing_smartd_pid ) if pid is None: await self._service("smartd-daemon", "reload", **kwargs) return os.kill(pid, signal.SIGKILL) await self._service("smartd-daemon", "start", **kwargs) async def _restart_smartd(self, **kwargs): await self.middleware.call("etc.generate", "smartd") pid = await self.middleware.run_in_thread(self._initializing_smartd_pid ) if pid is None: await self._service("smartd-daemon", "stop", force=True, **kwargs) await self._service("smartd-daemon", "restart", **kwargs) return os.kill(pid, signal.SIGKILL) await self._service("smartd-daemon", "start", **kwargs) async def _stop_smartd(self, **kwargs): pid = await self.middleware.run_in_thread(self._initializing_smartd_pid ) if pid is None: await self._service("smartd-daemon", "stop", force=True, **kwargs) return os.kill(pid, signal.SIGKILL) async def _reload_ssh(self, **kwargs): await self.middleware.call('etc.generate', 'ssh') await self.middleware.call('mdnsadvertise.restart') await self._service("openssh", "reload", **kwargs) await self._service("ix_sshd_save_keys", "start", quiet=True, **kwargs) async def _start_ssh(self, **kwargs): await self.middleware.call('etc.generate', 'ssh') await self.middleware.call('mdnsadvertise.restart') await self._service("openssh", "start", **kwargs) await self._service("ix_sshd_save_keys", "start", quiet=True, **kwargs) async def _stop_ssh(self, **kwargs): await self._service("openssh", "stop", force=True, **kwargs) await self.middleware.call('mdnsadvertise.restart') async def _restart_ssh(self, **kwargs): await self.middleware.call('etc.generate', 'ssh') await self._service("openssh", "stop", force=True, **kwargs) await self.middleware.call('mdnsadvertise.restart') await self._service("openssh", "restart", **kwargs) await self._service("ix_sshd_save_keys", "start", quiet=True, **kwargs) async def _start_ssl(self, **kwargs): await self.middleware.call('etc.generate', 'ssl') async def _start_s3(self, **kwargs): await self.middleware.call('etc.generate', 's3') await self._service("minio", "start", quiet=True, **kwargs) async def _reload_s3(self, **kwargs): await self.middleware.call('etc.generate', 's3') await self._service("minio", "restart", quiet=True, **kwargs) async def _reload_rsync(self, **kwargs): await self.middleware.call('etc.generate', 'rsync') await self._service("rsyncd", "restart", **kwargs) async def _restart_rsync(self, **kwargs): await self._stop_rsync() await self._start_rsync() async def _start_rsync(self, **kwargs): await self.middleware.call('etc.generate', 'rsync') await self._service("rsyncd", "start", **kwargs) async def _stop_rsync(self, **kwargs): await self._service("rsyncd", "stop", force=True, **kwargs) async def _started_nis(self, **kwargs): return (await self.middleware.call('nis.started')), [] async def _start_nis(self, **kwargs): return (await self.middleware.call('nis.start')), [] async def _restart_nis(self, **kwargs): await self.middleware.call('nis.stop') return (await self.middleware.call('nis.start')), [] async def _stop_nis(self, **kwargs): return (await self.middleware.call('nis.stop')), [] async def _started_ldap(self, **kwargs): return await self.middleware.call('ldap.started'), [] async def _start_ldap(self, **kwargs): return await self.middleware.call('ldap.start'), [] async def _stop_ldap(self, **kwargs): return await self.middleware.call('ldap.stop'), [] async def _restart_ldap(self, **kwargs): await self.middleware.call('ldap.stop') return await self.middleware.call('ldap.start'), [] async def _start_lldp(self, **kwargs): await self._service("ladvd", "start", **kwargs) async def _stop_lldp(self, **kwargs): await self._service("ladvd", "stop", force=True, **kwargs) async def _restart_lldp(self, **kwargs): await self._service("ladvd", "stop", force=True, **kwargs) await self._service("ladvd", "restart", **kwargs) async def _started_activedirectory(self, **kwargs): return await self.middleware.call('activedirectory.started'), [] async def _start_activedirectory(self, **kwargs): return await self.middleware.call('activedirectory.start'), [] async def _stop_activedirectory(self, **kwargs): return await self.middleware.call('activedirectory.stop'), [] async def _restart_activedirectory(self, **kwargs): await self.middleware.call('kerberos.stop'), [] return await self.middleware.call('activedirectory.start'), [] async def _reload_activedirectory(self, **kwargs): await self._service("samba_server", "stop", force=True, **kwargs) await self._service("samba_server", "start", quiet=True, **kwargs) async def _restart_syslogd(self, **kwargs): await self.middleware.call("etc.generate", "syslogd") await self._system("/etc/local/rc.d/syslog-ng restart") async def _start_syslogd(self, **kwargs): await self.middleware.call("etc.generate", "syslogd") await self._system("/etc/local/rc.d/syslog-ng start") async def _stop_syslogd(self, **kwargs): await self._system("/etc/local/rc.d/syslog-ng stop") async def _reload_syslogd(self, **kwargs): await self.middleware.call("etc.generate", "syslogd") await self._system("/etc/local/rc.d/syslog-ng reload") async def _start_tftp(self, **kwargs): await self.middleware.call('etc.generate', 'inetd') await self._service("inetd", "start", **kwargs) async def _reload_tftp(self, **kwargs): await self.middleware.call('etc.generate', 'inetd') await self._service("inetd", "stop", force=True, **kwargs) await self._service("inetd", "restart", **kwargs) async def _restart_tftp(self, **kwargs): await self.middleware.call('etc.generate', 'inetd') await self._service("inetd", "stop", force=True, **kwargs) await self._service("inetd", "restart", **kwargs) async def _restart_cron(self, **kwargs): await self.middleware.call('etc.generate', 'cron') async def _start_motd(self, **kwargs): await self.middleware.call('etc.generate', 'motd') await self._service("motd", "start", quiet=True, **kwargs) async def _start_ttys(self, **kwargs): await self.middleware.call('etc.generate', 'ttys') async def _reload_ftp(self, **kwargs): await self.middleware.call("etc.generate", "ftp") await self._service("proftpd", "restart", **kwargs) async def _restart_ftp(self, **kwargs): await self._stop_ftp() await self._start_ftp() async def _start_ftp(self, **kwargs): await self.middleware.call("etc.generate", "ftp") await self._service("proftpd", "start", **kwargs) async def _stop_ftp(self, **kwargs): await self._service("proftpd", "stop", force=True, **kwargs) async def _start_ups(self, **kwargs): await self.middleware.call('ups.dismiss_alerts') await self.middleware.call('etc.generate', 'ups') await self._service("nut", "start", **kwargs) await self._service("nut_upsmon", "start", **kwargs) await self._service("nut_upslog", "start", **kwargs) if await self.started('collectd'): asyncio.ensure_future(self.restart('collectd')) async def _stop_ups(self, **kwargs): await self.middleware.call('ups.dismiss_alerts') await self._service("nut_upslog", "stop", force=True, **kwargs) await self._service("nut_upsmon", "stop", force=True, **kwargs) await self._service("nut", "stop", force=True, **kwargs) if await self.started('collectd'): asyncio.ensure_future(self.restart('collectd')) async def _restart_ups(self, **kwargs): await self.middleware.call('ups.dismiss_alerts') await self.middleware.call('etc.generate', 'ups') await self._service("nut", "stop", force=True, onetime=True) # We need to wait on upsmon service to die properly as multiple processes are # associated with it and in most cases they haven't exited when a restart is initiated # for upsmon which fails as the older process is still running. await self._service("nut_upsmon", "stop", force=True, onetime=True) upsmon_processes = await run(['pgrep', '-x', 'upsmon'], encoding='utf8', check=False) if upsmon_processes.returncode == 0: gone, alive = await self.middleware.run_in_thread( psutil.wait_procs, map(lambda v: psutil.Process(int(v)), upsmon_processes.stdout.split()), timeout=10) if alive: for pid in map(int, upsmon_processes.stdout.split()): with contextlib.suppress(ProcessLookupError): os.kill(pid, signal.SIGKILL) await self._service("nut_upslog", "stop", force=True, onetime=True) await self._service("nut", "restart", onetime=True) await self._service("nut_upsmon", "restart", onetime=True) await self._service("nut_upslog", "restart", onetime=True) if await self.started('collectd'): asyncio.ensure_future(self.restart('collectd')) async def _started_ups(self, **kwargs): return await self._started('upsmon') async def _start_afp(self, **kwargs): await self.middleware.call("etc.generate", "afpd") await self._service("netatalk", "start", **kwargs) async def _stop_afp(self, **kwargs): await self._service("netatalk", "stop", force=True, **kwargs) # when netatalk stops if afpd or cnid_metad is stuck # they'll get left behind, which can cause issues # restarting netatalk. await self._system("pkill -9 afpd") await self._system("pkill -9 cnid_metad") async def _restart_afp(self, **kwargs): await self._stop_afp() await self._start_afp() async def _reload_afp(self, **kwargs): await self.middleware.call("etc.generate", "afpd") await self._system("killall -1 netatalk") async def _reload_nfs(self, **kwargs): await self.middleware.call("etc.generate", "nfsd") await self.middleware.call("nfs.setup_v4") await self._service("mountd", "reload", force=True, **kwargs) async def _restart_nfs(self, **kwargs): await self._stop_nfs(**kwargs) await self._start_nfs(**kwargs) async def _stop_nfs(self, **kwargs): await self._service("lockd", "stop", force=True, **kwargs) await self._service("statd", "stop", force=True, **kwargs) await self._service("nfsd", "stop", force=True, **kwargs) await self._service("mountd", "stop", force=True, **kwargs) await self._service("nfsuserd", "stop", force=True, **kwargs) await self._service("gssd", "stop", force=True, **kwargs) await self._service("rpcbind", "stop", force=True, **kwargs) async def _start_nfs(self, **kwargs): await self.middleware.call("etc.generate", "nfsd") await self._service("rpcbind", "start", quiet=True, **kwargs) await self.middleware.call("nfs.setup_v4") await self._service("mountd", "start", quiet=True, **kwargs) await self._service("nfsd", "start", quiet=True, **kwargs) await self._service("statd", "start", quiet=True, **kwargs) await self._service("lockd", "start", quiet=True, **kwargs) async def _start_dynamicdns(self, **kwargs): await self.middleware.call('etc.generate', 'inadyn') await self._service("inadyn", "start", **kwargs) async def _restart_dynamicdns(self, **kwargs): await self.middleware.call('etc.generate', 'inadyn') await self._service("inadyn", "stop", force=True, **kwargs) await self._service("inadyn", "restart", **kwargs) async def _reload_dynamicdns(self, **kwargs): await self.middleware.call('etc.generate', 'inadyn') await self._service("inadyn", "stop", force=True, **kwargs) await self._service("inadyn", "restart", **kwargs) async def _restart_system(self, **kwargs): asyncio.ensure_future( self.middleware.call('system.reboot', {'delay': 3})) async def _stop_system(self, **kwargs): asyncio.ensure_future( self.middleware.call('system.shutdown', {'delay': 3})) async def _reload_cifs(self, **kwargs): await self.middleware.call("etc.generate", "smb_share") await self._service("samba_server", "reload", force=True, **kwargs) await self._service("mdnsd", "restart", **kwargs) # After mdns is restarted we need to reload netatalk to have it rereregister # with mdns. Ticket #7133 await self._service("netatalk", "reload", **kwargs) async def _restart_cifs(self, **kwargs): await self.middleware.call("etc.generate", "smb") await self.middleware.call("etc.generate", "smb_share") await self._service("samba_server", "stop", force=True, **kwargs) await self._service("samba_server", "restart", quiet=True, **kwargs) await self._service("mdnsd", "restart", **kwargs) # After mdns is restarted we need to reload netatalk to have it rereregister # with mdns. Ticket #7133 await self._service("netatalk", "reload", **kwargs) async def _start_cifs(self, **kwargs): await self.middleware.call("etc.generate", "smb") await self.middleware.call("etc.generate", "smb_share") await self._service("samba_server", "start", quiet=True, **kwargs) try: await self.middleware.call("smb.add_admin_group", "", True) except Exception as e: raise CallError(e) async def _stop_cifs(self, **kwargs): await self._service("samba_server", "stop", force=True, **kwargs) async def _started_cifs(self, **kwargs): if await self._service("samba_server", "status", quiet=True, onetime=True, **kwargs): return False, [] else: return True, [] async def _start_snmp(self, **kwargs): await self.middleware.call("etc.generate", "snmpd") await self._service("snmpd", "start", quiet=True, **kwargs) await self._service("snmp-agent", "start", quiet=True, **kwargs) async def _stop_snmp(self, **kwargs): await self._service("snmp-agent", "stop", quiet=True, **kwargs) await self._service("snmpd", "stop", quiet=True, **kwargs) async def _restart_snmp(self, **kwargs): await self._service("snmp-agent", "stop", quiet=True, **kwargs) await self._service("snmpd", "stop", force=True, **kwargs) await self.middleware.call("etc.generate", "snmpd") await self._service("snmpd", "start", quiet=True, **kwargs) await self._service("snmp-agent", "start", quiet=True, **kwargs) async def _reload_snmp(self, **kwargs): await self._service("snmp-agent", "stop", quiet=True, **kwargs) await self._service("snmpd", "stop", force=True, **kwargs) await self.middleware.call("etc.generate", "snmpd") await self._service("snmpd", "start", quiet=True, **kwargs) await self._service("snmp-agent", "start", quiet=True, **kwargs) async def _restart_http(self, **kwargs): await self.middleware.call("etc.generate", "nginx") await self.middleware.call('mdnsadvertise.restart') await self._service("nginx", "restart", **kwargs) async def _reload_http(self, **kwargs): await self.middleware.call("etc.generate", "nginx") await self.middleware.call('mdnsadvertise.restart') await self._service("nginx", "reload", **kwargs) async def _reload_loader(self, **kwargs): await self.middleware.call("etc.generate", "loader") async def _restart_disk(self, **kwargs): await self._reload_disk(**kwargs) async def _reload_disk(self, **kwargs): await self.middleware.call('etc.generate', 'fstab') await self._service("mountlate", "start", quiet=True, **kwargs) # Restarting rrdcached can take a long time. There is no # benefit in waiting for it, since even if it fails it will not # tell the user anything useful. asyncio.ensure_future(self.restart("collectd", kwargs)) async def _reload_user(self, **kwargs): await self.middleware.call("etc.generate", "user") await self.middleware.call('etc.generate', 'aliases') await self.middleware.call('etc.generate', 'sudoers') await self.reload("cifs", kwargs) async def _restart_system_datasets(self, **kwargs): systemdataset = await self.middleware.call('systemdataset.setup') if not systemdataset: return None if systemdataset['syslog']: await self.restart("syslogd", kwargs) await self.restart("cifs", kwargs) # Restarting rrdcached can take a long time. There is no # benefit in waiting for it, since even if it fails it will not # tell the user anything useful. # Restarting rrdcached will make sure that we start/restart collectd as well asyncio.ensure_future(self.restart("rrdcached", kwargs)) async def _start_netdata(self, **kwargs): await self.middleware.call('etc.generate', 'netdata') await self._service('netdata', 'start', **kwargs) async def _restart_netdata(self, **kwargs): await self._service('netdata', 'stop') await self._start_netdata(**kwargs) @private async def identify_process(self, name): for service, definition in self.SERVICE_DEFS.items(): if definition.procname == name: return service @accepts(Int("pid"), Int("timeout", default=10)) def terminate_process(self, pid, timeout): """ Terminate process by `pid`. First send `TERM` signal, then, if was not terminated in `timeout` seconds, send `KILL` signal. Returns `true` is process has been successfully terminated with `TERM` and `false` if we had to use `KILL`. """ try: process = psutil.Process(pid) except psutil.NoSuchProcessError: raise CallError("Process does not exist") process.terminate() gone, alive = psutil.wait_procs([process], timeout) if not alive: return True alive[0].kill() return False
class ChartReleaseService(Service): class Config: namespace = 'chart.release' @accepts(Str('release_name'), Dict( 'rollback_options', Bool('force_rollback', default=False), Bool('recreate_resources', default=False), Bool('rollback_snapshot', default=True), Str('item_version', required=True), )) @returns(Ref('chart_release_entry')) @job(lock=lambda args: f'chart_release_rollback_{args[0]}') async def rollback(self, job, release_name, options): """ Rollback a chart release to a previous chart version. `item_version` is version which we want to rollback a chart release to. `rollback_snapshot` is a boolean value which when set will rollback snapshots of any PVC's or ix volumes being consumed by the chart release. `force_rollback` is a boolean which when set will force rollback operation to move forward even if no snapshots are found. This is only useful when `rollback_snapshot` is set. `recreate_resources` is a boolean which will delete and then create the kubernetes resources on rollback of chart release. This should be used with caution as if chart release is consuming immutable objects like a PVC, the rollback operation can't be performed and will fail as helm tries to do a 3 way patch for rollback. Rollback is functional for the actual configuration of the release at the `item_version` specified and any associated `ix_volumes` with any PVC's which were consuming chart release storage class. """ await self.middleware.call('kubernetes.validate_k8s_setup') release = await self.middleware.call('chart.release.query', [['id', '=', release_name]], { 'extra': { 'history': True, 'retrieve_resources': True }, 'get': True, }) rollback_version = options['item_version'] if rollback_version not in release['history']: raise CallError( f'Unable to find {rollback_version!r} item version in {release_name!r} history', errno=errno.ENOENT) chart_path = os.path.join(release['path'], 'charts', rollback_version) if not await self.middleware.run_in_thread( lambda: os.path.exists(chart_path)): raise CallError( f'Unable to locate {chart_path!r} path for rolling back', errno=errno.ENOENT) chart_details = await self.middleware.call( 'catalog.item_version_details', chart_path) await self.middleware.call('catalog.version_supported_error_check', chart_details) history_item = release['history'][rollback_version] history_ver = str(history_item['version']) force_rollback = options['force_rollback'] helm_force_flag = options['recreate_resources'] # If helm force flag is specified, we should see if the chart release is consuming any PVC's and if it is, # let's not initiate a rollback as it's destined to fail by helm if helm_force_flag and release['resources']['persistent_volume_claims']: raise CallError( f'Unable to rollback {release_name!r} as chart release is consuming PVC. ' 'Please unset recreate_resources to proceed with rollback.') # TODO: Remove the logic for ix_volumes as moving on we would be only snapshotting volumes and only rolling # it back snap_data = {'volumes': False, 'volumes/ix_volumes': False} for snap in snap_data: volumes_ds = os.path.join(release['dataset'], snap) snap_name = f'{volumes_ds}@{history_ver}' if await self.middleware.call('zfs.snapshot.query', [['id', '=', snap_name]]): snap_data[snap] = snap_name if options['rollback_snapshot'] and not any( snap_data.values()) and not force_rollback: raise CallError( f'Unable to locate {", ".join(snap_data.keys())!r} snapshot(s) for {release_name!r} volumes', errno=errno.ENOENT) current_dataset_paths = { os.path.join('/mnt', d['id']) for d in await self.middleware.call('zfs.dataset.query', [[ 'id', '^', f'{os.path.join(release["dataset"], "volumes/ix_volumes")}/' ]]) } history_datasets = { d['hostPath'] for d in history_item['config'].get('ixVolumes', []) } if history_datasets - current_dataset_paths: raise CallError( 'Please specify a rollback version where following iX Volumes are not being used as they don\'t ' f'exist anymore: {", ".join(d.split("/")[-1] for d in history_datasets - current_dataset_paths)}' ) job.set_progress(25, 'Initial validation complete') # TODO: Upstream helm does not have ability to force stop a release, until we have that ability # let's just try to do a best effort to scale down scaleable workloads and then scale them back up job.set_progress(45, 'Scaling down workloads') scale_stats = await (await self.middleware.call( 'chart.release.scale', release_name, {'replica_count': 0})).wait(raise_error=True) job.set_progress(50, 'Rolling back chart release') command = [] if helm_force_flag: command.append('--force') cp = await run( [ 'helm', 'rollback', release_name, history_ver, '-n', get_namespace(release_name), '--recreate-pods' ] + command, check=False, ) await self.middleware.call('chart.release.sync_secrets_for_release', release_name) await self.middleware.call('chart.release.refresh_events_state', release_name) # Helm rollback is a bit tricky, it utilizes rollout functionality of kubernetes and rolls back the # resources to specified version. However in this process, if the rollback is to fail for any reason, it's # possible that some k8s resources got rolled back to previous version whereas others did not. We should # in this case check if helm treats the chart release as on the previous version of the chart release, we # should still do a rollback of snapshots in this case and raise the error afterwards. However if helm # does not recognize the chart release on a previous version, we can just raise it right away then. current_version = (await self.middleware.call( 'chart.release.get_instance', release_name))['chart_metadata']['version'] if current_version != rollback_version and cp.returncode: raise CallError( f'Failed to rollback {release_name!r} chart release to {rollback_version!r}: {cp.stderr.decode()}' ) # We are going to remove old chart version copies await self.middleware.call( 'chart.release.remove_old_upgraded_chart_version_copies', os.path.join(release['path'], 'charts'), rollback_version, ) if options['rollback_snapshot'] and any(snap_data.values()): for snap_name in filter(bool, snap_data.values()): await self.middleware.call( 'zfs.snapshot.rollback', snap_name, { 'force': True, 'recursive': True, 'recursive_clones': True, 'recursive_rollback': True, }) break await self.middleware.call( 'chart.release.scale_release_internal', release['resources'], None, scale_stats['before_scale'], True, ) await self.middleware.call( 'chart.release.clear_chart_release_portal_cache', release_name) job.set_progress(100, 'Rollback complete for chart release') await self.middleware.call( 'chart.release.chart_releases_update_checks_internal', [['id', '=', release_name]]) if cp.returncode: # This means that helm partially rolled back k8s resources and recognizes the chart release as being # on the previous version, we should raise an appropriate exception explaining the behavior raise CallError( f'Failed to complete rollback {release_name!r} chart release to {rollback_version}. Chart release\'s ' f'datasets have been rolled back to {rollback_version!r} version\'s snapshot. Errors encountered ' f'during rollback were: {cp.stderr.decode()}') if await self.middleware.call( 'chart.release.get_chart_releases_consuming_outdated_certs', [['id', '=', release_name]]): await self.middleware.call('chart.release.update', release_name, {'values': {}}) return await self.middleware.call('chart.release.get_instance', release_name) @private def remove_old_upgraded_chart_version_copies(self, charts_path, current_version): c_v = parse_version(current_version) for v_path in filter(lambda p: p != current_version, os.listdir(charts_path)): if parse_version(v_path) > c_v: shutil.rmtree(path=os.path.join(charts_path, v_path), ignore_errors=True)
class KubernetesDeploymentService(CRUDService): class Config: namespace = 'k8s.deployment' private = True @filterable async def query(self, filters, options): async with api_client() as (api, context): if len(filters) == 1 and len(filters[0]) == 3 and list(filters[0])[:2] == ['metadata.namespace', '=']: func = functools.partial(context['apps_api'].list_namespaced_deployment, namespace=filters[0][2]) else: func = functools.partial(context['apps_api'].list_deployment_for_all_namespaces) deployments = [d.to_dict() for d in (await func()).items] if options['extra'].get('events'): events = await self.middleware.call( 'kubernetes.get_events_of_resource_type', 'Deployment', [d['metadata']['uid'] for d in deployments] ) for deployment in deployments: deployment['events'] = events[deployment['metadata']['uid']] return filter_list(deployments, filters, options) @accepts( Dict( 'deployment_create', Str('namespace', required=True), Dict('body', additional_attrs=True, required=True), register=True ) ) async def do_create(self, data): async with api_client() as (api, context): try: await context['apps_api'].create_namespaced_deployment(namespace=data['namespace'], body=data['body']) except client.exceptions.ApiException as e: raise CallError(f'Unable to create deployment: {e}') else: return await self.query([ ['metadata.name', '=', data['body']['metadata']['name']], ['metadata.namespace', '=', data['namespace']], ], {'get': True}) @accepts( Str('name'), Ref('deployment_create'), ) async def do_update(self, name, data): async with api_client() as (api, context): try: await context['apps_api'].patch_namespaced_deployment( name, namespace=data['namespace'], body=data['body'] ) except client.exceptions.ApiException as e: raise CallError(f'Unable to patch {name} deployment: {e}') else: return await self.query([ ['metadata.name', '=', name], ['metadata.namespace', '=', data['namespace']], ], {'get': True}) @accepts( Str('name'), Dict( 'deployment_delete_options', Str('namespace', required=True), ) ) async def do_delete(self, name, options): async with api_client() as (api, context): try: await context['apps_api'].delete_namespaced_deployment(name, options['namespace']) except client.exceptions.ApiException as e: raise CallError(f'Unable to delete deployment: {e}') else: return True
class KerberosKeytabService(TDBWrapCRUDService): class Config: datastore = 'directoryservice.kerberoskeytab' datastore_prefix = 'keytab_' namespace = 'kerberos.keytab' cli_namespace = 'directory_service.kerberos.keytab' ENTRY = Patch( 'kerberos_keytab_create', 'kerberos_keytab_entry', ('add', Int('id')), ) @accepts( Dict('kerberos_keytab_create', Str('file', max_length=None), Str('name'), register=True)) async def do_create(self, data): """ Create a kerberos keytab. Uploaded keytab files will be merged with the system keytab under /etc/krb5.keytab. `file` b64encoded kerberos keytab `name` name for kerberos keytab """ verrors = ValidationErrors() verrors.add_child('kerberos_principal_create', await self._validate(data)) if verrors: raise verrors id = await super().do_create(data) await self.middleware.call('etc.generate', 'kerberos') return await self._get_instance(id) @accepts(Int('id', required=True), Patch( 'kerberos_keytab_create', 'kerberos_keytab_update', )) async def do_update(self, id, data): """ Update kerberos keytab by id. """ old = await self._get_instance(id) new = old.copy() new.update(data) verrors = ValidationErrors() verrors.add_child('kerberos_principal_update', await self._validate(new)) if verrors: raise verrors await super().do_update(id, new) await self.middleware.call('etc.generate', 'kerberos') return await self._get_instance(id) @accepts(Int('id')) async def do_delete(self, id): """ Delete kerberos keytab by id, and force regeneration of system keytab. """ await super().do_delete(id) if os.path.exists(keytab['SYSTEM'].value): os.remove(keytab['SYSTEM'].value) await self.middleware.call('etc.generate', 'kerberos') await self._cleanup_kerberos_principals() await self.middleware.call('kerberos.stop') try: await self.middleware.call('kerberos.start') except Exception as e: self.logger.debug( 'Failed to start kerberos service after deleting keytab entry: %s' % e) @accepts(Dict( 'keytab_data', Str('name', required=True), )) @returns(Ref('kerberos_keytab_entry')) @job(lock='upload_keytab', pipes=['input'], check_pipes=True) async def upload_keytab(self, job, data): """ Upload a keytab file. This method expects the keytab file to be uploaded using the /_upload/ endpoint. """ ktmem = io.BytesIO() await self.middleware.run_in_thread(shutil.copyfileobj, job.pipes.input.r, ktmem) b64kt = base64.b64encode(ktmem.getvalue()) return await self.middleware.call('kerberos.keytab.create', { 'name': data['name'], 'file': b64kt.decode() }) @private async def legacy_validate(self, keytab): err = await self._validate({'file': keytab}) try: err.check() except Exception as e: raise CallError(e) @private async def _cleanup_kerberos_principals(self): principal_choices = await self.middleware.call( 'kerberos.keytab.kerberos_principal_choices') ad = await self.middleware.call('activedirectory.config') ldap = await self.middleware.call('ldap.config') if ad['kerberos_principal'] and ad[ 'kerberos_principal'] not in principal_choices: await self.middleware.call('activedirectory.update', {'kerberos_principal': ''}) if ldap['kerberos_principal'] and ldap[ 'kerberos_principal'] not in principal_choices: await self.middleware.call('ldap.update', {'kerberos_principal': ''}) @private async def do_ktutil_list(self, data): kt = data.get("kt_name", keytab.SYSTEM.value) ktutil = await run(["klist", "-tek", kt], check=False) if ktutil.returncode != 0: raise CallError(ktutil.stderr.decode()) ret = ktutil.stdout.decode().splitlines() if len(ret) < 4: return [] return '\n'.join(ret[3:]) @private async def _validate(self, data): """ For now validation is limited to checking if we can resolve the hostnames configured for the kdc, admin_server, and kpasswd_server can be resolved by DNS, and if the realm can be resolved by DNS. """ verrors = ValidationErrors() try: decoded = base64.b64decode(data['file']) except Exception as e: verrors.add( "kerberos.keytab_create", f"Keytab is a not a properly base64-encoded string: [{e}]") return verrors with open(keytab['TEST'].value, "wb") as f: f.write(decoded) try: await self.do_ktutil_list({"kt_name": keytab['TEST'].value}) except CallError as e: verrors.add("kerberos.keytab_create", f"Failed to validate keytab: [{e.errmsg}]") os.unlink(keytab['TEST'].value) return verrors @private async def _ktutil_list(self, keytab_file=keytab['SYSTEM'].value): keytab_entries = [] try: kt_list_output = await self.do_ktutil_list( {"kt_name": keytab_file}) except Exception as e: self.logger.warning("Failed to list kerberos keytab [%s]: %s", keytab_file, e) kt_list_output = None if not kt_list_output: return keytab_entries for idx, line in enumerate(kt_list_output.splitlines()): fields = line.split() keytab_entries.append({ 'slot': idx + 1, 'kvno': int(fields[0]), 'principal': fields[3], 'etype': fields[4][1:-1].strip('DEPRECATED:'), 'etype_deprecated': fields[4][1:].startswith('DEPRECATED'), 'date': time.strptime(fields[1], '%m/%d/%y'), }) return keytab_entries @accepts() @returns( List('system-keytab', items=[ Dict('keytab-entry', Int('slot'), Int('kvno'), Str('principal'), Str('etype'), Bool('etype_deprecated'), Datetime('date')) ])) async def system_keytab_list(self): """ Returns content of system keytab (/etc/krb5.keytab). """ kt_list = await self._ktutil_list() parsed = [] for entry in kt_list: entry['date'] = time.mktime(entry['date']) parsed.append(entry) return parsed @private async def _get_nonsamba_principals(self, keytab_list): """ Generate list of Kerberos principals that are not the AD machine account. """ ad = await self.middleware.call('activedirectory.config') pruned_list = [] for i in keytab_list: if ad['netbiosname'].casefold() not in i['principal'].casefold(): pruned_list.append(i) return pruned_list @private async def _generate_tmp_keytab(self): """ Generate a temporary keytab to separate out the machine account keytab principal. ktutil copy returns 1 even if copy succeeds. """ with contextlib.suppress(OSError): os.remove(keytab['SAMBA'].value) kt_copy = await Popen(['ktutil'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) output = await kt_copy.communicate( f'rkt {keytab.SYSTEM.value}\nwkt {keytab.SAMBA.value}\nq\n'.encode( )) if output[1]: raise CallError( f"failed to generate [{keytab['SAMBA'].value}]: {output[1].decode()}" ) @private async def _prune_keytab_principals(self, to_delete=[]): """ Delete all keytab entries from the tmp keytab that are not samba entries. The pruned keytab must be written to a new file to avoid duplication of entries. """ rkt = f"rkt {keytab.SAMBA.value}" wkt = "wkt /var/db/system/samba4/samba_mit.keytab" delents = "\n".join(f"delent {x['slot']}" for x in reversed(to_delete)) ktutil_remove = await Popen(['ktutil'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) output = await ktutil_remove.communicate( f'{rkt}\n{delents}\n{wkt}\nq\n'.encode()) if output[1]: raise CallError(output[1].decode()) with contextlib.suppress(OSError): os.remove(keytab.SAMBA.value) os.rename("/var/db/system/samba4/samba_mit.keytab", keytab.SAMBA.value) @private async def kerberos_principal_choices(self): """ Keytabs typically have multiple entries for same principal (differentiated by enc_type). Since the enctype isn't relevant in this situation, only show unique principal names. Return empty list if system keytab doesn't exist. """ if not os.path.exists(keytab['SYSTEM'].value): return [] try: keytab_list = await self._ktutil_list() except Exception as e: self.logger.trace( '"ktutil list" failed. Generating empty list of kerberos principal choices. Error: %s' % e) return [] kerberos_principals = [] for entry in keytab_list: if entry['principal'] not in kerberos_principals: kerberos_principals.append(entry['principal']) return sorted(kerberos_principals) @private async def has_nfs_principal(self): """ This method checks whether the kerberos keytab contains an nfs service principal """ principals = await self.kerberos_principal_choices() for p in principals: if p.startswith("nfs/"): return True return False @private async def store_samba_keytab(self): """ Samba will automatically generate system keytab entries for the AD machine account (netbios name with '$' appended), and maintain them through machine account password changes. Copy the system keytab, parse it, and update the corresponding keytab entry in the freenas configuration database. The current system kerberos keytab and compare with a cached copy before overwriting it when a new keytab is generated through middleware 'etc.generate kerberos'. """ if not os.path.exists(keytab['SYSTEM'].value): return False encoded_keytab = None keytab_list = await self._ktutil_list() items_to_remove = await self._get_nonsamba_principals(keytab_list) await self._generate_tmp_keytab() await self._prune_keytab_principals(items_to_remove) with open(keytab['SAMBA'].value, 'rb') as f: encoded_keytab = base64.b64encode(f.read()) if not encoded_keytab: self.logger.debug( f"Failed to generate b64encoded version of {keytab['SAMBA'].name}" ) return False keytab_file = encoded_keytab.decode() entry = await self.query([('name', '=', 'AD_MACHINE_ACCOUNT')]) if not entry: await self.middleware.call('kerberos.keytab.direct_create', { 'name': 'AD_MACHINE_ACCOUNT', 'file': keytab_file }) else: id = entry[0]['id'] updated_entry = {'name': 'AD_MACHINE_ACCOUNT', 'file': keytab_file} await self.middleware.call('kerberos.keytab.direct_update', id, updated_entry) sambakt = await self.query([('name', '=', 'AD_MACHINE_ACCOUNT')]) if sambakt: return sambakt[0]['id'] @periodic(3600) @private async def check_updated_keytab(self): """ Check mtime of current kerberos keytab. If it has changed since last check, assume that samba has updated it behind the scenes and that the configuration database needs to be updated to reflect the change. """ if not await self.middleware.call('system.ready'): return old_mtime = 0 ad_state = await self.middleware.call('activedirectory.get_state') if ad_state == 'DISABLED' or not os.path.exists( keytab['SYSTEM'].value): return if (await self.middleware.call("smb.get_smb_ha_mode")) in ("LEGACY", "CLUSTERED"): return if await self.middleware.call('cache.has_key', 'KEYTAB_MTIME'): old_mtime = await self.middleware.call('cache.get', 'KEYTAB_MTIME') new_mtime = (os.stat(keytab['SYSTEM'].value)).st_mtime if old_mtime == new_mtime: return ts = await self.middleware.call( 'directoryservices.get_last_password_change') if ts['dbconfig'] == ts['secrets']: return self.logger.debug( "Machine account password has changed. Stored copies of " "kerberos keytab and directory services secrets will now " "be updated.") await self.middleware.call('directoryservices.backup_secrets') await self.store_samba_keytab() self.logger.trace('Updating stored AD machine account kerberos keytab') await self.middleware.call('cache.put', 'KEYTAB_MTIME', (os.stat(keytab['SYSTEM'].value)).st_mtime)
class KerberosService(TDBWrapConfigService): tdb_defaults = {"id": 1, "appdefaults_aux": "", "libdefaults_aux": ""} class Config: service = "kerberos" datastore = 'directoryservice.kerberossettings' datastore_prefix = "ks_" cli_namespace = "directory_service.kerberos.settings" @accepts( Dict('kerberos_settings_update', Str('appdefaults_aux', max_length=None), Str('libdefaults_aux', max_length=None), update=True)) async def do_update(self, data): """ `appdefaults_aux` add parameters to "appdefaults" section of the krb5.conf file. `libdefaults_aux` add parameters to "libdefaults" section of the krb5.conf file. """ verrors = ValidationErrors() old = await self.config() new = old.copy() new.update(data) verrors.add_child( 'kerberos_settings_update', await self._validate_appdefaults(new['appdefaults_aux'])) verrors.add_child( 'kerberos_settings_update', await self._validate_libdefaults(new['libdefaults_aux'])) verrors.check() await super().do_update(data) await self.middleware.call('etc.generate', 'kerberos') return await self.config() @private @accepts( Dict('kerberos-options', Str('ccache', enum=[x.name for x in krb5ccache], default=krb5ccache.SYSTEM.name), register=True)) async def _klist_test(self, data): """ Returns false if there is not a TGT or if the TGT has expired. """ krb_ccache = krb5ccache[data['ccache']] klist = await run(['klist', '-s', '-c', krb_ccache.value], check=False) if klist.returncode != 0: return False return True @private async def check_ticket(self): valid_ticket = await self._klist_test() if not valid_ticket: raise CallError("Kerberos ticket is required.", errno.ENOKEY) return @private async def _validate_param_type(self, data): supported_validation_types = [ 'boolean', 'cctype', 'etypes', 'keytab', ] if data['ptype'] not in supported_validation_types: return if data['ptype'] == 'boolean': if data['value'].upper() not in ['YES', 'TRUE', 'NO', 'FALSE']: raise CallError(f'[{data["value"]}] is not boolean') if data['ptype'] == 'etypes': for e in data['value'].split(' '): try: KRB_ETYPE(e) except Exception: raise CallError( f'[{e}] is not a supported encryption type') if data['ptype'] == 'cctype': available_types = ['FILE', 'MEMORY', 'DIR'] if data['value'] not in available_types: raise CallError( f'[{data["value"]}] is an unsupported cctype. ' f'Available types are {", ".join(available_types)}. ' 'This parameter is case-sensitive') if data['ptype'] == 'keytab': try: keytab(data['value']) except Exception: raise CallError( f'{data["value"]} is an unsupported keytab path') @private async def _validate_appdefaults(self, appdefaults): verrors = ValidationErrors() for line in appdefaults.splitlines(): param = line.split('=') if len(param) == 2 and (param[1].strip())[0] != '{': validated_param = list( filter(lambda x: param[0].strip() in (x.value)[0], KRB_AppDefaults)) if not validated_param: verrors.add( 'kerberos_appdefaults', f'{param[0]} is an invalid appdefaults parameter.') continue try: await self._validate_param_type({ 'ptype': (validated_param[0]).value[1], 'value': param[1].strip() }) except Exception as e: verrors.add('kerberos_appdefaults', f'{param[0]} has invalid value: {e.errmsg}.') continue return verrors @private async def _validate_libdefaults(self, libdefaults): verrors = ValidationErrors() for line in libdefaults.splitlines(): param = line.split('=') if len(param) == 2: validated_param = list( filter(lambda x: param[0].strip() in (x.value)[0], KRB_LibDefaults)) if not validated_param: verrors.add( 'kerberos_libdefaults', f'{param[0]} is an invalid libdefaults parameter.') continue try: await self._validate_param_type({ 'ptype': (validated_param[0]).value[1], 'value': param[1].strip() }) except Exception as e: verrors.add('kerberos_libdefaults', f'{param[0]} has invalid value: {e.errmsg}.') else: verrors.add('kerberos_libdefaults', f'{line} is an invalid libdefaults parameter.') return verrors @private @accepts( Dict( "get-kerberos-creds", Str("dstype", required=True, enum=[x.name for x in DSType]), OROperator(Dict('ad_parameters', Str('bindname'), Str('bindpw'), Str('domainname'), Str('kerberos_principal')), Dict('ldap_parameters', Str('binddn'), Str('bindpw'), Int('kerberos_realm'), Str('kerberos_principal')), name='conf', required=True))) async def get_cred(self, data): ''' Get kerberos cred from directory services config to use for `do_kinit`. ''' conf = data.get('conf', {}) if conf.get('kerberos_principal'): return {'kerberos_principal': conf['kerberos_principal']} verrors = ValidationErrors() dstype = DSType[data['dstype']] if dstype is DSType.DS_TYPE_ACTIVEDIRECTORY: for k in ['bindname', 'bindpw', 'domainname']: if not conf.get(k): verrors.add(f'conf.{k}', 'Parameter is required.') verrors.check() return { 'username': f'{conf["bindname"]}@{conf["domainname"].upper()}', 'password': conf['bindpw'] } for k in ['binddn', 'bindpw', 'kerberos_realm']: if not conf.get(k): verrors.add(f'conf.{k}', 'Parameter is required.') verrors.check() krb_realm = await self.middleware.call( 'kerberos.realm.query', [('id', '=', conf['kerberos_realm'])], {'get': True}) bind_cn = (conf['binddn'].split(','))[0].split("=") return { 'username': f'{bind_cn[1]}@{krb_realm["realm"]}', 'password': conf['bindpw'] } @private @accepts( Dict( 'do_kinit', OROperator( Dict('kerberos_username_password', Str('username', required=True), Str('password', required=True, private=True), register=True), Dict( 'kerberos_keytab', Str('kerberos_principal', required=True), ), name='krb5_cred', required=True, ), Patch( 'kerberos-options', 'kinit-options', ('add', { 'name': 'renewal_period', 'type': 'int', 'default': 7 }), ))) async def do_kinit(self, data): ccache = krb5ccache[data['kinit-options']['ccache']] cmd = [ 'kinit', '-r', str(data['kinit-options']['renewal_period']), '-c', ccache.value ] creds = data['krb5_cred'] has_principal = 'kerberos_principal' in creds if has_principal: cmd.extend(['-k', creds['kerberos_principal']]) kinit = await run(cmd, check=False) if kinit.returncode != 0: raise CallError( f"kinit with principal [{creds['kerberos_principal']}] " f"failed: {kinit.stderr.decode()}") return cmd.append(creds['username']) kinit = await Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) output = await kinit.communicate(input=creds['password'].encode()) if kinit.returncode != 0: raise CallError( f"kinit with password failed: {output[1].decode()}") return True @private async def _kinit(self): """ For now we only check for kerberos realms explicitly configured in AD and LDAP. """ ad = await self.middleware.call('activedirectory.config') ldap = await self.middleware.call('ldap.config') await self.middleware.call('etc.generate', 'kerberos') payload = {} if ad['enable']: payload = { 'dstype': DSType.DS_TYPE_ACTIVEDIRECTORY.name, 'conf': { 'bindname': ad['bindname'], 'bindpw': ad['bindpw'], 'domainname': ad['domainname'], 'kerberos_principal': ad['kerberos_principal'], } } if ldap['enable'] and ldap['kerberos_realm']: payload = { 'dstype': DSType.DS_TYPE_LDAP.name, 'conf': { 'binddn': ldap['binddn'], 'bindpw': ldap['bindpw'], 'kerberos_realm': ldap['kerberos_realm'], 'kerberos_principal': ldap['kerberos_principal'], } } if not payload: return cred = await self.get_cred(payload) await self.do_kinit({'krb5_cred': cred}) @private async def parse_klist(self, data): ad = data.get("ad") ldap = data.get("ldap") klistin = data.get("klistin") tickets = klistin.splitlines() default_principal = None tlen = len(tickets) if ad['enable']: dstype = DSType.DS_TYPE_ACTIVEDIRECTORY elif ldap['enable']: dstype = DSType.DS_TYPE_LDAP else: return {"ad_TGT": [], "ldap_TGT": []} parsed_klist = [] for idx, e in enumerate(tickets): if e.startswith('Default'): default_principal = (e.split(':')[1]).strip() if e and e[0].isdigit(): d = e.split(" ") issued = time.strptime(d[0], "%m/%d/%y %H:%M:%S") expires = time.strptime(d[1], "%m/%d/%y %H:%M:%S") client = default_principal server = d[2] flags = None etype = None next_two = [idx + 1, idx + 2] for i in next_two: if i >= tlen: break if tickets[i][0].isdigit(): break if tickets[i].startswith("\tEtype"): etype = tickets[i].strip() break if tickets[i].startswith("\trenew"): flags = tickets[i].split("Flags: ")[1] continue extra = tickets[i].split(", ", 1) flags = extra[0].strip() etype = extra[1].strip() parsed_klist.append({ 'issued': issued, 'expires': expires, 'client': client, 'server': server, 'etype': etype, 'flags': flags, }) return { "ad_TGT": parsed_klist if dstype == DSType.DS_TYPE_ACTIVEDIRECTORY else [], "ldap_TGT": parsed_klist if dstype == DSType.DS_TYPE_LDAP else [], } @private async def _get_cached_klist(self): """ Try to get retrieve cached kerberos tgt info. If it hasn't been cached, perform klist, parse it, put it in cache, then return it. """ if await self.middleware.call('cache.has_key', 'KRB_TGT_INFO'): return (await self.middleware.call('cache.get', 'KRB_TGT_INFO')) ad = await self.middleware.call('activedirectory.config') ldap = await self.middleware.call('ldap.config') ad_TGT = [] ldap_TGT = [] parsed_klist = {} if not ad['enable'] and not ldap['enable']: return {'ad_TGT': ad_TGT, 'ldap_TGT': ldap_TGT} if not ad['enable'] and not ldap['kerberos_realm']: return {'ad_TGT': ad_TGT, 'ldap_TGT': ldap_TGT} if not await self.status(): await self.start() try: klist = await asyncio.wait_for(run(['klist', '-ef'], check=False, stdout=subprocess.PIPE), timeout=10.0) except Exception as e: await self.stop() raise CallError( "Attempt to list kerberos tickets failed with error: %s", e) if klist.returncode != 0: await self.stop() raise CallError( f'klist failed with error: {klist.stderr.decode()}') klist_output = klist.stdout.decode() parsed_klist = await self.parse_klist({ "klistin": klist_output, "ad": ad, "ldap": ldap, }) if parsed_klist['ad_TGT'] or parsed_klist['ldap_TGT']: await self.middleware.call('cache.put', 'KRB_TGT_INFO', parsed_klist) return parsed_klist @private async def renew(self): """ Compare timestamp of cached TGT info with current timestamp. If we're within 5 minutes of expire time, renew the TGT via 'kinit -R'. """ tgt_info = await self._get_cached_klist() ret = True must_renew = False must_reinit = False if not tgt_info['ad_TGT'] and not tgt_info['ldap_TGT']: must_reinit = True if tgt_info['ad_TGT']: permitted_buffer = datetime.timedelta(minutes=5) current_time = datetime.datetime.now() for entry in tgt_info['ad_TGT']: tgt_expiry_time = datetime.datetime.fromtimestamp( time.mktime(entry['expires'])) delta = tgt_expiry_time - current_time if datetime.timedelta(minutes=0) > delta: must_reinit = True break if permitted_buffer > delta: must_renew = True break if tgt_info['ldap_TGT']: permitted_buffer = datetime.timedelta(minutes=5) current_time = datetime.datetime.now() for entry in tgt_info['ldap_TGT']: tgt_expiry_time = datetime.datetime.fromtimestamp( time.mktime(entry['expires'])) delta = tgt_expiry_time - current_time if datetime.timedelta(minutes=0) > delta: must_reinit = True break if permitted_buffer > delta: must_renew = True break if must_renew and not must_reinit: try: kinit = await asyncio.wait_for(run(['kinit', '-R'], check=False), timeout=15) if kinit.returncode != 0: raise CallError( f'kinit -R failed with error: {kinit.stderr.decode()}') self.logger.debug('Successfully renewed kerberos TGT') await self.middleware.call('cache.pop', 'KRB_TGT_INFO') except asyncio.TimeoutError: self.logger.debug( 'Attempt to renew kerberos TGT failed after 15 seconds.') if must_reinit: ret = await self.start() await self.middleware.call('cache.pop', 'KRB_TGT_INFO') return ret @private async def status(self): """ Experience in production environments has indicated that klist can hang indefinitely. Fail if we hang for more than 10 seconds. This should force a kdestroy and new attempt to kinit (depending on why we are checking status). _klist_test will return false if there is not a TGT or if the TGT has expired. """ try: ret = await asyncio.wait_for(self._klist_test(), timeout=10.0) return ret except asyncio.TimeoutError: self.logger.debug( 'kerberos ticket status check timed out after 10 seconds.') return False @private @accepts(Ref('kerberos-options')) async def kdestroy(self, data): kdestroy = await run( ['kdestroy', '-c', krb5ccache[data['ccache']].value], check=False) if kdestroy.returncode != 0: raise CallError( f'kdestroy failed with error: {kdestroy.stderr.decode()}') return @private async def stop(self): await self.middleware.call('cache.pop', 'KRB_TGT_INFO') await self.kdestroy() return True @private async def start(self, realm=None, kinit_timeout=30): """ kinit can hang because it depends on DNS. If it has not returned within 30 seconds, it is safe to say that it has failed. """ await self.middleware.call('etc.generate', 'kerberos') try: await asyncio.wait_for(self._kinit(), timeout=kinit_timeout) except asyncio.TimeoutError: raise CallError( f'Timed out hung kinit after [{kinit_timeout}] seconds')
class ChartReleaseService(Service): class Config: namespace = 'chart.release' @private async def retrieve_pod_with_containers(self, release_name): await self.middleware.call('kubernetes.validate_k8s_setup') release = await self.middleware.call('chart.release.query', [['id', '=', release_name]], { 'get': True, 'extra': { 'retrieve_resources': True } }) choices = {} for pod in release['resources']['pods']: choices[pod['metadata']['name']] = [] for container in (pod['status']['container_statuses'] or []): choices[pod['metadata']['name']].append(container['name']) return choices @accepts(Str('release_name')) @returns( Dict( additional_attrs=True, example={'plex-d4559844b-zcgq9': ['plex']}, )) async def pod_console_choices(self, release_name): """ Returns choices for console access to a chart release. Output is a dictionary with names of pods as keys and containing names of containers which the pod comprises of. """ return await self.retrieve_pod_with_containers(release_name) @accepts(Str('release_name')) @returns( Dict( additional_attrs=True, example={'plex-d4559844b-zcgq9': ['plex']}, )) async def pod_logs_choices(self, release_name): """ Returns choices for accessing logs of any container in any pod in a chart release. """ return await self.retrieve_pod_with_containers(release_name) @private async def validate_pod_log_args(self, release_name, pod_name, container_name): choices = await self.pod_logs_choices(release_name) if pod_name not in choices: raise CallError(f'Unable to locate {pod_name!r} pod.', errno=errno.ENOENT) elif container_name not in choices[pod_name]: raise CallError( f'Unable to locate {container_name!r} container in {pod_name!r} pod.', errno=errno.ENOENT) @accepts(Str('release_name'), Dict( 'options', Int('limit_bytes', default=None, null=True, validators=[Range(min=1)]), Int('tail_lines', default=500, validators=[Range(min=1)], null=True), Str('pod_name', required=True, empty=False), Str('container_name', required=True, empty=False), )) @returns() @job(lock='chart_release_logs', pipes=['output']) def pod_logs(self, job, release_name, options): """ Export logs of `options.container_name` container in `options.pod_name` pod in `release_name` chart release. `options.tail_lines` is an option to select how many lines of logs to retrieve for the said container. It defaults to 500. If set to `null`, it will retrieve complete logs of the container. `options.limit_bytes` is an option to select how many bytes to retrieve from the tail lines selected. If set to null ( which is the default ), it will not limit the bytes returned. To clarify, `options.tail_lines` is applied first and the required number of lines are retrieved and then `options.limit_bytes` is applied. Please refer to websocket documentation for downloading the file. """ self.middleware.call_sync('chart.release.validate_pod_log_args', release_name, options['pod_name'], options['container_name']) logs = self.middleware.call_sync('k8s.pod.get_logs', options['pod_name'], options['container_name'], get_namespace(release_name), options['tail_lines'], options['limit_bytes']) job.pipes.output.w.write((logs or '').encode()) @accepts() @returns(Dict(additional_attrs=True)) async def nic_choices(self): """ Available choices for NIC which can be added to a pod in a chart release. """ return await self.middleware.call('interface.choices') @accepts() @returns(List(items=[Int('used_port')])) async def used_ports(self): """ Returns ports in use by applications. """ return sorted( list( set({ port['port'] for chart_release in await self.middleware.call( 'chart.release.query') for port in chart_release['used_ports'] }))) @accepts() @returns(List(items=[Ref('certificate_entry')])) async def certificate_choices(self): """ Returns certificates which can be used by applications. """ return await self.middleware.call( 'certificate.query', [['revoked', '=', False], ['cert_type_CSR', '=', False], ['parsed', '=', True]]) @accepts() @returns(List(items=[Ref('certificateauthority_entry')])) async def certificate_authority_choices(self): """ Returns certificate authorities which can be used by applications. """ return await self.middleware.call( 'certificateauthority.query', [['revoked', '=', False], ['parsed', '=', True]]) @private async def retrieve_pv_pvc_mapping(self, release_name): chart_release = await self.middleware.call( 'chart.release.query', [['id', '=', release_name]], { 'get': True, 'extra': { 'retrieve_resources': True } }) return await self.retrieve_pv_pvc_mapping_internal(chart_release) @private async def retrieve_pv_pvc_mapping_internal(self, chart_release): mapping = {} release_vol_ds = os.path.join(chart_release['dataset'], 'volumes') zfs_volumes = { zv['metadata']['name']: zv for zv in await self.middleware.call( 'k8s.zv.query', [['spec.poolName', '=', release_vol_ds]]) } for pv in chart_release['resources']['persistent_volumes']: claim_name = pv['spec'].get('claim_ref', {}).get('name') if claim_name: csi_spec = pv['spec']['csi'] volumes_ds = csi_spec['volume_attributes'][ 'openebs.io/poolname'] if (os.path.join(chart_release['dataset'], 'volumes') != volumes_ds or csi_spec['volume_handle'] not in zfs_volumes): # We are only going to backup/restore pvc's which were consuming # their respective storage class and we have related zfs volume present continue pv_name = pv['metadata']['name'] mapping[claim_name] = { 'name': pv_name, 'pv_details': pv, 'dataset': os.path.join(volumes_ds, csi_spec['volume_handle']), 'zv_details': zfs_volumes[csi_spec['volume_handle']], } return mapping @private async def create_update_storage_class_for_chart_release( self, release_name, volumes_path): storage_class_name = get_storage_class_name(release_name) storage_class = await self.middleware.call( 'k8s.storage_class.retrieve_storage_class_manifest') storage_class['metadata']['name'] = storage_class_name storage_class['parameters']['poolname'] = volumes_path if await self.middleware.call( 'k8s.storage_class.query', [['metadata.name', '=', storage_class_name]]): await self.middleware.call('k8s.storage_class.update', storage_class_name, storage_class) else: await self.middleware.call('k8s.storage_class.create', storage_class) @accepts(Str('release_name')) @returns( Dict( Int('available', required=True), Int('desired', required=True), Str('status', required=True, enum=['ACTIVE', 'DEPLOYING', 'STOPPED']))) async def pod_status(self, release_name): """ Retrieve available/desired pods status for a chart release and it's current state. """ status = {'available': 0, 'desired': 0} for resource in (Resources.DEPLOYMENT, Resources.STATEFULSET): for r_data in await self.middleware.call( f'k8s.{resource.name.lower()}.query', [['metadata.namespace', '=', get_namespace(release_name)]], ): # Detail about ready_replicas/replicas # https://stackoverflow.com/questions/66317251/couldnt-understand-availablereplicas- # readyreplicas-unavailablereplicas-in-dep status.update({ 'available': (r_data['status']['ready_replicas'] or 0), 'desired': (r_data['status']['replicas'] or 0), }) pod_diff = status['available'] - status['desired'] r_status = 'ACTIVE' if pod_diff == 0 and status['desired'] == 0: r_status = 'STOPPED' elif pod_diff < 0: r_status = 'DEPLOYING' return { 'status': r_status, **status, }
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 AlertServiceService(CRUDService): class Config: datastore = "system.alertservice" datastore_extend = "alertservice._extend" datastore_order_by = ["name"] @accepts() async def list_types(self): """ List all types of supported Alert services which can be configured with the system. """ return [{ "name": name, "title": factory.title, } for name, factory in sorted(ALERT_SERVICES_FACTORIES.items(), key=lambda i: i[1].title.lower())] @private async def _extend(self, service): try: service["type__title"] = ALERT_SERVICES_FACTORIES[ service["type"]].title except KeyError: service["type__title"] = "<Unknown>" return service @private async def _compress(self, service): service.pop("type__title") return service @private async def _validate(self, service, schema_name): verrors = ValidationErrors() factory = ALERT_SERVICES_FACTORIES.get(service["type"]) if factory is None: verrors.add(f"{schema_name}.type", "This field has invalid value") raise verrors verrors.add_child( f"{schema_name}.attributes", validate_attributes(list(factory.schema.attrs.values()), service)) if verrors: raise verrors @accepts( Dict( "alert_service_create", Str("name"), Str("type", required=True), Dict("attributes", additional_attrs=True), Str("level", enum=list(AlertLevel.__members__)), Bool("enabled"), register=True, )) async def do_create(self, data): """ Create an Alert Service of specified `type`. If `enabled`, it sends alerts to the configured `type` of Alert Service. .. examples(websocket):: Create an Alert Service of Mail `type` :::javascript { "id": "6841f242-840a-11e6-a437-00e04d680384", "msg": "method", "method": "alertservice.create", "params": [{ "name": "Test Email Alert", "enabled": true, "type": "Mail", "attributes": { "email": "*****@*****.**" }, "settings": { "VolumeVersion": "HOURLY" } }] } """ await self._validate(data, "alert_service_create") data["id"] = await self.middleware.call("datastore.insert", self._config.datastore, data) await self._extend(data) return data @accepts(Int("id"), Patch( "alert_service_create", "alert_service_update", ("attr", { "update": True }), )) async def do_update(self, id, data): """ Update Alert Service of `id`. """ old = await self.middleware.call( "datastore.query", self._config.datastore, [("id", "=", id)], { "extend": self._config.datastore_extend, "get": True }) new = old.copy() new.update(data) await self._validate(new, "alert_service_update") await self._compress(new) await self.middleware.call("datastore.update", self._config.datastore, id, new) await self._extend(new) return new @accepts(Int("id")) async def do_delete(self, id): """ Delete Alert Service of `id`. """ return await self.middleware.call("datastore.delete", self._config.datastore, id) @accepts(Ref('alert_service_create')) async def test(self, data): """ Send a test alert using `type` of Alert Service. .. examples(websocket):: Send a test alert using Alert Service of Mail `type`. :::javascript { "id": "6841f242-840a-11e6-a437-00e04d680384", "msg": "method", "method": "alertservice.test", "params": [{ "name": "Test Email Alert", "enabled": true, "type": "Mail", "attributes": { "email": "*****@*****.**" }, "settings": {} }] } """ await self._validate(data, "alert_service_test") factory = ALERT_SERVICES_FACTORIES.get(data["type"]) if factory is None: self.logger.error("Alert service %r does not exist", data["type"]) return False try: alert_service = factory(self.middleware, data["attributes"]) except Exception: self.logger.error( "Error creating alert service %r with parameters=%r", data["type"], data["attributes"], exc_info=True) return False master_node = "A" if not await self.middleware.call("system.is_freenas"): if await self.middleware.call("failover.licensed"): master_node = await self.middleware.call("failover.node") test_alert = Alert( TestAlertClass, node=master_node, datetime=datetime.utcnow(), last_occurrence=datetime.utcnow(), _uuid="test", ) try: await alert_service.send([test_alert], [], [test_alert]) except Exception: self.logger.error("Error in alert service %r", data["type"], exc_info=True) return False return True
class ServiceService(CRUDService): SERVICE_DEFS = { 's3': ServiceDefinition('minio', '/var/run/minio.pid'), 'ssh': ServiceDefinition('sshd', '/var/run/sshd.pid'), 'rsync': ServiceDefinition('rsync', '/var/run/rsyncd.pid'), 'nfs': ServiceDefinition('nfsd', None), 'afp': ServiceDefinition('netatalk', None), 'cifs': ServiceDefinition('smbd', '/var/run/samba4/smbd.pid'), 'dynamicdns': ServiceDefinition('inadyn', None), 'snmp': ServiceDefinition('snmpd', '/var/run/net_snmpd.pid'), 'ftp': ServiceDefinition('proftpd', '/var/run/proftpd.pid'), 'tftp': ServiceDefinition('inetd', '/var/run/inetd.pid'), 'iscsitarget': ServiceDefinition('ctld', '/var/run/ctld.pid'), 'lldp': ServiceDefinition('ladvd', '/var/run/ladvd.pid'), 'ups': ServiceDefinition('upsd', '/var/db/nut/upsd.pid'), 'upsmon': ServiceDefinition('upsmon', '/var/db/nut/upsmon.pid'), 'smartd': ServiceDefinition('smartd', 'smartd-daemon', '/var/run/smartd-daemon.pid'), 'webshell': ServiceDefinition(None, '/var/run/webshell.pid'), 'webdav': ServiceDefinition('httpd', '/var/run/httpd.pid'), 'netdata': ServiceDefinition('netdata', '/var/db/netdata/netdata.pid') } @filterable async def query(self, filters=None, options=None): if options is None: options = {} options['prefix'] = 'srv_' services = await self.middleware.call('datastore.query', 'services.services', filters, options) # In case a single service has been requested if not isinstance(services, list): services = [services] jobs = { asyncio.ensure_future(self._get_status(entry)): entry for entry in services } if jobs: done, pending = await asyncio.wait(list(jobs.keys()), timeout=15) def result(task): """ Method to handle results of the coroutines. In case of error or timeout, provide UNKNOWN state. """ result = None try: if task in done: result = task.result() except Exception: pass if result is None: entry = jobs.get(task) self.logger.warn('Failed to get status for %s', entry['service']) entry['state'] = 'UNKNOWN' entry['pids'] = [] return entry else: return result services = list(map(result, jobs)) return filter_list(services, filters, options) @accepts( Str('id_or_name'), Dict( 'service-update', Bool('enable', default=False), ), ) async def do_update(self, id_or_name, data): """ Update service entry of `id_or_name`. Currently it only accepts `enable` option which means whether the service should start on boot. """ if not id_or_name.isdigit(): svc = await self.middleware.call('datastore.query', 'services.services', [('srv_service', '=', id_or_name)]) if not svc: raise CallError(f'Service {id_or_name} not found.', errno.ENOENT) id_or_name = svc[0]['id'] return await self.middleware.call('datastore.update', 'services.services', id_or_name, {'srv_enable': data['enable']}) @accepts( Str('service'), Dict( 'service-control', Bool('onetime', default=True), Bool('wait', default=None, null=True), Bool('sync', default=None, null=True), register=True, ), ) async def start(self, service, options=None): """ Start the service specified by `service`. The helper will use method self._start_[service]() to start the service. If the method does not exist, it would fallback using service(8).""" await self.middleware.call_hook('service.pre_action', service, 'start', options) sn = self._started_notify("start", service) await self._simplecmd("start", service, options) return await self.started(service, sn) async def started(self, service, sn=None): """ Test if service specified by `service` has been started. """ if sn: await self.middleware.run_in_thread(sn.join) try: svc = await self.query([('service', '=', service)], {'get': True}) self.middleware.send_event('service.query', 'CHANGED', fields=svc) return svc['state'] == 'RUNNING' except IndexError: f = getattr(self, '_started_' + service, None) if callable(f): if inspect.iscoroutinefunction(f): return (await f())[0] else: return f()[0] else: return (await self._started(service))[0] @accepts( Str('service'), Ref('service-control'), ) async def stop(self, service, options=None): """ Stop the service specified by `service`. The helper will use method self._stop_[service]() to stop the service. If the method does not exist, it would fallback using service(8).""" await self.middleware.call_hook('service.pre_action', service, 'stop', options) sn = self._started_notify("stop", service) await self._simplecmd("stop", service, options) return await self.started(service, sn) @accepts( Str('service'), Ref('service-control'), ) async def restart(self, service, options=None): """ Restart the service specified by `service`. The helper will use method self._restart_[service]() to restart the service. If the method does not exist, it would fallback using service(8).""" await self.middleware.call_hook('service.pre_action', service, 'restart', options) sn = self._started_notify("restart", service) await self._simplecmd("restart", service, options) return await self.started(service, sn) @accepts( Str('service'), Ref('service-control'), ) async def reload(self, service, options=None): """ Reload the service specified by `service`. The helper will use method self._reload_[service]() to reload the service. If the method does not exist, the helper will try self.restart of the service instead.""" await self.middleware.call_hook('service.pre_action', service, 'reload', options) try: await self._simplecmd("reload", service, options) except Exception as e: await self.restart(service, options) return await self.started(service) async def _get_status(self, service): f = getattr(self, '_started_' + service['service'], None) if callable(f): if inspect.iscoroutinefunction(f): running, pids = await f() else: running, pids = f() else: running, pids = await self._started(service['service']) if running: state = 'RUNNING' else: state = 'STOPPED' service['state'] = state service['pids'] = pids return service async def _simplecmd(self, action, what, options=None): self.logger.debug("Calling: %s(%s) ", action, what) f = getattr(self, '_' + action + '_' + what, None) if f is None: # Provide generic start/stop/restart verbs for rc.d scripts if what in self.SERVICE_DEFS: if self.SERVICE_DEFS[what].rc_script: what = self.SERVICE_DEFS[what].rc_script if action in ("start", "stop", "restart", "reload"): if action == 'restart': await self._system("/usr/sbin/service " + what + " forcestop ") await self._service(what, action, **options) else: raise ValueError("Internal error: Unknown command") else: call = f(**(options or {})) if inspect.iscoroutinefunction(f): await call async def _system(self, cmd, options=None): stdout = DEVNULL if options and 'stdout' in options: stdout = options['stdout'] stderr = DEVNULL if options and 'stderr' in options: stderr = options['stderr'] proc = await Popen(cmd, stdout=stdout, stderr=stderr, shell=True, close_fds=True) await proc.communicate() return proc.returncode async def _service(self, service, verb, **options): onetime = options.pop('onetime', None) force = options.pop('force', None) quiet = options.pop('quiet', None) extra = options.pop('extra', '') # force comes before one which comes before quiet # they are mutually exclusive preverb = '' if force: preverb = 'force' elif onetime: preverb = 'one' elif quiet: preverb = 'quiet' return await self._system('/usr/sbin/service {} {}{} {}'.format( service, preverb, verb, extra, ), options) def _started_notify(self, verb, what): """ The check for started [or not] processes is currently done in 2 steps This is the first step which involves a thread StartNotify that watch for event before actually start/stop rc.d scripts Returns: StartNotify object if the service is known or None otherwise """ if what in self.SERVICE_DEFS: sn = StartNotify(verb=verb, pidfile=self.SERVICE_DEFS[what].pidfile) sn.start() return sn else: return None async def _started(self, what, notify=None): """ This is the second step:: Wait for the StartNotify thread to finish and then check for the status of pidfile/procname using pgrep Returns: True whether the service is alive, False otherwise """ if what in self.SERVICE_DEFS: if notify: await self.middleware.run_in_thread(notify.join) if self.SERVICE_DEFS[what].pidfile: pgrep = "/bin/pgrep -F {}{}".format( self.SERVICE_DEFS[what].pidfile, ' ' + self.SERVICE_DEFS[what].procname if self.SERVICE_DEFS[what].procname else '', ) else: pgrep = "/bin/pgrep {}".format(self.SERVICE_DEFS[what].procname) proc = await Popen(pgrep, shell=True, stdout=PIPE, stderr=PIPE, close_fds=True) data = (await proc.communicate())[0].decode() if proc.returncode == 0: return True, [ int(i) for i in data.strip().split('\n') if i.isdigit() ] return False, [] async def _start_webdav(self, **kwargs): await self._service("ix-apache", "start", force=True, **kwargs) await self._service("apache24", "start", **kwargs) async def _stop_webdav(self, **kwargs): await self._service("apache24", "stop", **kwargs) async def _restart_webdav(self, **kwargs): await self._service("apache24", "stop", force=True, **kwargs) await self._service("ix-apache", "start", force=True, **kwargs) await self._service("apache24", "restart", **kwargs) async def _reload_webdav(self, **kwargs): await self._service("ix-apache", "start", force=True, **kwargs) await self._service("apache24", "reload", **kwargs) async def _restart_django(self, **kwargs): await self._service("django", "restart", **kwargs) async def _start_webshell(self, **kwargs): await self._system("/usr/local/bin/python /usr/local/www/freenasUI/tools/webshell.py") async def _restart_webshell(self, **kwargs): try: with open('/var/run/webshell.pid', 'r') as f: pid = f.read() os.kill(int(pid), signal.SIGTERM) time.sleep(0.2) os.kill(int(pid), signal.SIGKILL) except: pass await self._system("ulimit -n 1024 && /usr/local/bin/python /usr/local/www/freenasUI/tools/webshell.py") async def _restart_iscsitarget(self, **kwargs): await self._service("ix-ctld", "start", force=True, **kwargs) await self._service("ctld", "stop", force=True, **kwargs) await self._service("ix-ctld", "start", quiet=True, **kwargs) await self._service("ctld", "restart", **kwargs) async def _start_iscsitarget(self, **kwargs): await self._service("ix-ctld", "start", quiet=True, **kwargs) await self._service("ctld", "start", **kwargs) async def _stop_iscsitarget(self, **kwargs): await self._service("ix-ctld", "stop", force=True, **kwargs) await self._service("ctld", "stop", force=True, **kwargs) async def _reload_iscsitarget(self, **kwargs): await self._service("ix-ctld", "start", quiet=True, **kwargs) await self._service("ctld", "reload", **kwargs) async def _start_collectd(self, **kwargs): await self._service("ix-collectd", "start", quiet=True, **kwargs) await self._service("collectd", "restart", **kwargs) async def _restart_collectd(self, **kwargs): await self._service("collectd", "stop", **kwargs) await self._service("ix-collectd", "start", quiet=True, **kwargs) await self._service("collectd", "start", **kwargs) async def _start_sysctl(self, **kwargs): await self._service("ix-sysctl", "start", quiet=True, **kwargs) async def _reload_sysctl(self, **kwargs): await self._service("ix-sysctl", "reload", **kwargs) async def _start_network(self, **kwargs): await self.middleware.call('interfaces.sync') await self.middleware.call('routes.sync') async def _stop_jails(self, **kwargs): for jail in await self.middleware.call('datastore.query', 'jails.jails'): try: await self.middleware.call('notifier.warden', 'stop', [], {'jail': jail['jail_host']}) except Exception as e: self.logger.debug(f'Failed to stop jail {jail["jail_host"]}', exc_info=True) async def _start_jails(self, **kwargs): await self._service("ix-warden", "start", **kwargs) for jail in await self.middleware.call('datastore.query', 'jails.jails'): if jail['jail_autostart']: try: await self.middleware.call('notifier.warden', 'start', [], {'jail': jail['jail_host']}) except Exception as e: self.logger.debug(f'Failed to start jail {jail["jail_host"]}', exc_info=True) await self._service("ix-plugins", "start", **kwargs) await self.reload("http", kwargs) async def _restart_jails(self, **kwargs): await self._stop_jails() await self._start_jails() async def _stop_pbid(self, **kwargs): await self._service("pbid", "stop", **kwargs) async def _start_pbid(self, **kwargs): await self._service("pbid", "start", **kwargs) async def _restart_pbid(self, **kwargs): await self._service("pbid", "restart", **kwargs) async def _reload_named(self, **kwargs): await self._service("named", "reload", **kwargs) async def _reload_hostname(self, **kwargs): await self._system('/bin/hostname ""') await self._service("ix-hostname", "start", quiet=True, **kwargs) await self._service("hostname", "start", quiet=True, **kwargs) await self._service("mdnsd", "restart", quiet=True, **kwargs) await self._service("collectd", "stop", **kwargs) await self._service("ix-collectd", "start", quiet=True, **kwargs) await self._service("collectd", "start", **kwargs) async def _reload_resolvconf(self, **kwargs): await self._reload_hostname() await self._service("ix-resolv", "start", quiet=True, **kwargs) async def _reload_networkgeneral(self, **kwargs): await self._reload_resolvconf() await self._service("routing", "restart", **kwargs) async def _reload_timeservices(self, **kwargs): await self._service("ix-localtime", "start", quiet=True, **kwargs) await self._service("ix-ntpd", "start", quiet=True, **kwargs) await self._service("ntpd", "restart", **kwargs) settings = await self.middleware.call( 'datastore.query', 'system.settings', [], {'order_by': ['-id'], 'get': True} ) os.environ['TZ'] = settings['stg_timezone'] time.tzset() async def _reload_smartd(self, **kwargs): await self._service("ix-smartd", "start", quiet=True, **kwargs) await self._service("smartd-daemon", "reload", **kwargs) async def _restart_smartd(self, **kwargs): await self.middleware.call("etc.generate", "smartd") await self._service("smartd-daemon", "stop", force=True, **kwargs) await self._service("smartd-daemon", "restart", **kwargs) async def _reload_ssh(self, **kwargs): await self._service("ix-sshd", "start", quiet=True, **kwargs) await self._service("ix_register", "reload", **kwargs) await self._service("openssh", "reload", **kwargs) await self._service("ix_sshd_save_keys", "start", quiet=True, **kwargs) async def _start_ssh(self, **kwargs): await self._service("ix-sshd", "start", quiet=True, **kwargs) await self._service("ix_register", "reload", **kwargs) await self._service("openssh", "start", **kwargs) await self._service("ix_sshd_save_keys", "start", quiet=True, **kwargs) async def _stop_ssh(self, **kwargs): await self._service("openssh", "stop", force=True, **kwargs) await self._service("ix_register", "reload", **kwargs) async def _restart_ssh(self, **kwargs): await self._service("ix-sshd", "start", quiet=True, **kwargs) await self._service("openssh", "stop", force=True, **kwargs) await self._service("ix_register", "reload", **kwargs) await self._service("openssh", "restart", **kwargs) await self._service("ix_sshd_save_keys", "start", quiet=True, **kwargs) async def _start_ssl(self, what=None): if what is not None: await self._service("ix-ssl", "start", quiet=True, extra=what) else: await self._service("ix-ssl", "start", quiet=True) async def _start_s3(self, **kwargs): await self.middleware.call('etc.generate', 's3') await self._service("minio", "start", quiet=True, stdout=None, stderr=None, **kwargs) async def _reload_s3(self, **kwargs): await self.middleware.call('etc.generate', 's3') await self._service("minio", "restart", quiet=True, stdout=None, stderr=None, **kwargs) async def _reload_rsync(self, **kwargs): await self._service("ix-rsyncd", "start", quiet=True, **kwargs) await self._service("rsyncd", "restart", **kwargs) async def _restart_rsync(self, **kwargs): await self._stop_rsync() await self._start_rsync() async def _start_rsync(self, **kwargs): await self._service("ix-rsyncd", "start", quiet=True, **kwargs) await self._service("rsyncd", "start", **kwargs) async def _stop_rsync(self, **kwargs): await self._service("rsyncd", "stop", force=True, **kwargs) async def _started_nis(self, **kwargs): res = False if not await self._system("/etc/directoryservice/NIS/ctl status"): res = True return res, [] async def _start_nis(self, **kwargs): res = False if not await self._system("/etc/directoryservice/NIS/ctl start"): res = True return res async def _restart_nis(self, **kwargs): res = False if not await self._system("/etc/directoryservice/NIS/ctl restart"): res = True return res async def _stop_nis(self, **kwargs): res = False if not await self._system("/etc/directoryservice/NIS/ctl stop"): res = True return res async def _started_ldap(self, **kwargs): if (await self._system('/usr/sbin/service ix-ldap status') != 0): return False, [] return await self.middleware.call('notifier.ldap_status'), [] async def _start_ldap(self, **kwargs): res = False if not await self._system("/etc/directoryservice/LDAP/ctl start"): res = True return res async def _stop_ldap(self, **kwargs): res = False if not await self._system("/etc/directoryservice/LDAP/ctl stop"): res = True return res async def _restart_ldap(self, **kwargs): res = False if not await self._system("/etc/directoryservice/LDAP/ctl restart"): res = True return res async def _start_lldp(self, **kwargs): await self._service("ladvd", "start", **kwargs) async def _stop_lldp(self, **kwargs): await self._service("ladvd", "stop", force=True, **kwargs) async def _restart_lldp(self, **kwargs): await self._service("ladvd", "stop", force=True, **kwargs) await self._service("ladvd", "restart", **kwargs) async def _clear_activedirectory_config(self): await self._system("/bin/rm -f /etc/directoryservice/ActiveDirectory/config") async def _started_activedirectory(self, **kwargs): # Perform a wbinfo -t because it's the most accurate single test we have to # detect problems with AD join. The default winbind timeout is 60 seconds (as of Samba 4.7). # This can be controlled by the smb4.conf parameter "winbind request timeout = " if await self._system('/usr/local/bin/wbinfo -t') != 0: self.logger.debug('AD monitor: wbinfo -t failed') return False, [] return True, [] async def _start_activedirectory(self, **kwargs): res = False if not await self._system("/etc/directoryservice/ActiveDirectory/ctl start"): res = True return res async def _stop_activedirectory(self, **kwargs): res = False if not await self._system("/etc/directoryservice/ActiveDirectory/ctl stop"): res = True return res async def _restart_activedirectory(self, **kwargs): res = False if not await self._system("/etc/directoryservice/ActiveDirectory/ctl restart"): res = True return res async def _reload_activedirectory(self, **kwargs): # Steps required to force winbind to connect to new DC if DC it's connected to goes down # We may need to expand the list of operations below to include fresh kinit. Some # information about winbind connection is stored in samba's gencache. In test cases, flushing # gencache (net cache flush) was not required to do this. await self._service("samba_server", "stop", force=True, **kwargs) await self._service("samba_server", "start", quiet=True, **kwargs) async def _started_domaincontroller(self, **kwargs): res = False if not await self._system("/etc/directoryservice/DomainController/ctl status"): res = True return res, [] async def _start_domaincontroller(self, **kwargs): res = False if not await self._system("/etc/directoryservice/DomainController/ctl start"): res = True return res async def _stop_domaincontroller(self, **kwargs): res = False if not await self._system("/etc/directoryservice/DomainController/ctl stop"): res = True return res async def _restart_domaincontroller(self, **kwargs): res = False if not await self._system("/etc/directoryservice/DomainController/ctl restart"): res = True return res async def _restart_syslogd(self, **kwargs): await self._service("ix-syslogd", "start", quiet=True, **kwargs) await self._system("/etc/local/rc.d/syslog-ng restart") async def _start_syslogd(self, **kwargs): await self._service("ix-syslogd", "start", quiet=True, **kwargs) await self._system("/etc/local/rc.d/syslog-ng start") async def _stop_syslogd(self, **kwargs): await self._system("/etc/local/rc.d/syslog-ng stop") async def _reload_syslogd(self, **kwargs): await self._service("ix-syslogd", "start", quiet=True, **kwargs) await self._system("/etc/local/rc.d/syslog-ng reload") async def _start_tftp(self, **kwargs): await self._service("ix-inetd", "start", quiet=True, **kwargs) await self._service("inetd", "start", **kwargs) async def _reload_tftp(self, **kwargs): await self._service("ix-inetd", "start", quiet=True, **kwargs) await self._service("inetd", "stop", force=True, **kwargs) await self._service("inetd", "restart", **kwargs) async def _restart_tftp(self, **kwargs): await self._service("ix-inetd", "start", quiet=True, **kwargs) await self._service("inetd", "stop", force=True, **kwargs) await self._service("inetd", "restart", **kwargs) async def _restart_cron(self, **kwargs): await self._service("ix-crontab", "start", quiet=True, **kwargs) async def _start_motd(self, **kwargs): await self._service("ix-motd", "start", quiet=True, **kwargs) await self._service("motd", "start", quiet=True, **kwargs) async def _start_ttys(self, **kwargs): await self._service("ix-ttys", "start", quiet=True, **kwargs) async def _reload_ftp(self, **kwargs): await self._service("ix-proftpd", "start", quiet=True, **kwargs) await self._service("proftpd", "restart", **kwargs) async def _restart_ftp(self, **kwargs): await self._stop_ftp() await self._start_ftp() async def _start_ftp(self, **kwargs): await self._service("ix-proftpd", "start", quiet=True, **kwargs) await self._service("proftpd", "start", **kwargs) async def _stop_ftp(self, **kwargs): await self._service("proftpd", "stop", force=True, **kwargs) async def _start_ups(self, **kwargs): await self._service("ix-ups", "start", quiet=True, **kwargs) await self._service("nut", "start", **kwargs) await self._service("nut_upsmon", "start", **kwargs) await self._service("nut_upslog", "start", **kwargs) async def _stop_ups(self, **kwargs): await self._service("nut_upslog", "stop", force=True, **kwargs) await self._service("nut_upsmon", "stop", force=True, **kwargs) await self._service("nut", "stop", force=True, **kwargs) async def _restart_ups(self, **kwargs): await self._service("ix-ups", "start", quiet=True, **kwargs) await self._service("nut", "stop", force=True, **kwargs) await self._service("nut_upsmon", "stop", force=True, **kwargs) await self._service("nut_upslog", "stop", force=True, **kwargs) await self._service("nut", "restart", **kwargs) await self._service("nut_upsmon", "restart", **kwargs) await self._service("nut_upslog", "restart", **kwargs) async def _started_ups(self, **kwargs): mode = (await self.middleware.call('datastore.query', 'services.ups', [], {'order_by': ['-id'], 'get': True}))['ups_mode'] if mode == "master": svc = "ups" else: svc = "upsmon" return await self._started(svc) async def _start_afp(self, **kwargs): await self._service("ix-afpd", "start", **kwargs) await self._service("netatalk", "start", **kwargs) async def _stop_afp(self, **kwargs): await self._service("netatalk", "stop", force=True, **kwargs) # when netatalk stops if afpd or cnid_metad is stuck # they'll get left behind, which can cause issues # restarting netatalk. await self._system("pkill -9 afpd") await self._system("pkill -9 cnid_metad") async def _restart_afp(self, **kwargs): await self._stop_afp() await self._start_afp() async def _reload_afp(self, **kwargs): await self._service("ix-afpd", "start", quiet=True, **kwargs) await self._system("killall -1 netatalk") async def _reload_nfs(self, **kwargs): await self.middleware.call("etc.generate", "nfsd") async def _restart_nfs(self, **kwargs): await self._stop_nfs(**kwargs) await self._start_nfs(**kwargs) async def _stop_nfs(self, **kwargs): await self._service("lockd", "stop", force=True, **kwargs) await self._service("statd", "stop", force=True, **kwargs) await self._service("nfsd", "stop", force=True, **kwargs) await self._service("mountd", "stop", force=True, **kwargs) await self._service("nfsuserd", "stop", force=True, **kwargs) await self._service("gssd", "stop", force=True, **kwargs) await self._service("rpcbind", "stop", force=True, **kwargs) async def _start_nfs(self, **kwargs): nfs = await self.middleware.call('datastore.config', 'services.nfs') await self.middleware.call("etc.generate", "nfsd") await self._service("rpcbind", "start", quiet=True, **kwargs) await self._service("gssd", "start", quiet=True, **kwargs) # Workaround to work with "onetime", since the rc scripts depend on rc flags. if nfs['nfs_srv_v4']: sysctl.filter('vfs.nfsd.server_max_nfsvers')[0].value = 4 if nfs['nfs_srv_v4_v3owner']: # Per RFC7530, sending NFSv3 style UID/GIDs across the wire is now allowed # You must have both of these sysctl's set to allow the desired functionality sysctl.filter('vfs.nfsd.enable_stringtouid')[0].value = 1 sysctl.filter('vfs.nfs.enable_uidtostring')[0].value = 1 await self._service("nfsuserd", "stop", force=True, **kwargs) else: sysctl.filter('vfs.nfsd.enable_stringtouid')[0].value = 0 sysctl.filter('vfs.nfs.enable_uidtostring')[0].value = 0 await self._service("nfsuserd", "start", quiet=True, **kwargs) else: sysctl.filter('vfs.nfsd.server_max_nfsvers')[0].value = 3 if nfs['nfs_srv_16']: await self._service("nfsuserd", "start", quiet=True, **kwargs) await self._service("mountd", "start", quiet=True, **kwargs) await self._service("nfsd", "start", quiet=True, **kwargs) await self._service("statd", "start", quiet=True, **kwargs) await self._service("lockd", "start", quiet=True, **kwargs) async def _force_stop_jail(self, **kwargs): await self._service("jail", "stop", force=True, **kwargs) async def _start_plugins(self, jail=None, plugin=None, **kwargs): if jail and plugin: await self._system("/usr/sbin/service ix-plugins forcestart %s:%s" % (jail, plugin)) else: await self._service("ix-plugins", "start", force=True, **kwargs) async def _stop_plugins(self, jail=None, plugin=None, **kwargs): if jail and plugin: await self._system("/usr/sbin/service ix-plugins forcestop %s:%s" % (jail, plugin)) else: await self._service("ix-plugins", "stop", force=True, **kwargs) async def _restart_plugins(self, jail=None, plugin=None): await self._stop_plugins(jail=jail, plugin=plugin) await self._start_plugins(jail=jail, plugin=plugin) async def _started_plugins(self, jail=None, plugin=None, **kwargs): res = False if jail and plugin: if self._system("/usr/sbin/service ix-plugins status %s:%s" % (jail, plugin)) == 0: res = True else: if await self._service("ix-plugins", "status", **kwargs) == 0: res = True return res, [] async def _start_dynamicdns(self, **kwargs): await self._service("ix-inadyn", "start", quiet=True, **kwargs) await self._service("inadyn", "start", **kwargs) async def _restart_dynamicdns(self, **kwargs): await self._service("ix-inadyn", "start", quiet=True, **kwargs) await self._service("inadyn", "stop", force=True, **kwargs) await self._service("inadyn", "restart", **kwargs) async def _reload_dynamicdns(self, **kwargs): await self._service("ix-inadyn", "start", quiet=True, **kwargs) await self._service("inadyn", "stop", force=True, **kwargs) await self._service("inadyn", "restart", **kwargs) async def _restart_system(self, **kwargs): asyncio.ensure_future(self._system("/bin/sleep 3 && /sbin/shutdown -r now")) async def _stop_system(self, **kwargs): asyncio.ensure_future(self._system("/bin/sleep 3 && /sbin/shutdown -p now")) async def _reload_cifs(self, **kwargs): await self._service("ix-pre-samba", "start", quiet=True, **kwargs) await self._service("samba_server", "reload", force=True, **kwargs) await self._service("ix-post-samba", "start", quiet=True, **kwargs) await self._service("mdnsd", "restart", **kwargs) # After mdns is restarted we need to reload netatalk to have it rereregister # with mdns. Ticket #7133 await self._service("netatalk", "reload", **kwargs) async def _restart_cifs(self, **kwargs): await self._service("ix-pre-samba", "start", quiet=True, **kwargs) await self._service("samba_server", "stop", force=True, **kwargs) await self._service("samba_server", "restart", quiet=True, **kwargs) await self._service("ix-post-samba", "start", quiet=True, **kwargs) await self._service("mdnsd", "restart", **kwargs) # After mdns is restarted we need to reload netatalk to have it rereregister # with mdns. Ticket #7133 await self._service("netatalk", "reload", **kwargs) async def _start_cifs(self, **kwargs): await self._service("ix-pre-samba", "start", quiet=True, **kwargs) await self._service("samba_server", "start", quiet=True, **kwargs) await self._service("ix-post-samba", "start", quiet=True, **kwargs) async def _stop_cifs(self, **kwargs): await self._service("samba_server", "stop", force=True, **kwargs) await self._service("ix-post-samba", "start", quiet=True, **kwargs) async def _started_cifs(self, **kwargs): if await self._service("samba_server", "status", quiet=True, onetime=True, **kwargs): return False, [] else: return True, [] async def _start_snmp(self, **kwargs): await self._service("ix-snmpd", "start", quiet=True, **kwargs) await self._service("snmpd", "start", quiet=True, **kwargs) await self._service("snmp-agent", "start", quiet=True, **kwargs) async def _stop_snmp(self, **kwargs): await self._service("snmp-agent", "stop", quiet=True, **kwargs) await self._service("snmpd", "stop", quiet=True, **kwargs) async def _restart_snmp(self, **kwargs): await self._service("snmp-agent", "stop", quiet=True, **kwargs) await self._service("snmpd", "stop", force=True, **kwargs) await self._service("ix-snmpd", "start", quiet=True, **kwargs) await self._service("snmpd", "start", quiet=True, **kwargs) await self._service("snmp-agent", "start", quiet=True, **kwargs) async def _reload_snmp(self, **kwargs): await self._service("snmp-agent", "stop", quiet=True, **kwargs) await self._service("snmpd", "stop", force=True, **kwargs) await self._service("ix-snmpd", "start", quiet=True, **kwargs) await self._service("snmpd", "start", quiet=True, **kwargs) await self._service("snmp-agent", "start", quiet=True, **kwargs) async def _restart_http(self, **kwargs): await self._service("ix-nginx", "start", quiet=True, **kwargs) await self._service("ix_register", "reload", **kwargs) await self._service("nginx", "restart", **kwargs) async def _reload_http(self, **kwargs): await self._service("ix-nginx", "start", quiet=True, **kwargs) await self._service("ix_register", "reload", **kwargs) await self._service("nginx", "reload", **kwargs) async def _reload_loader(self, **kwargs): await self._service("ix-loader", "reload", **kwargs) async def _start_loader(self, **kwargs): await self._service("ix-loader", "start", quiet=True, **kwargs) async def _restart_disk(self, **kwargs): await self._reload_disk(**kwargs) async def _reload_disk(self, **kwargs): await self._service("ix-fstab", "start", quiet=True, **kwargs) await self._service("ix-swap", "start", quiet=True, **kwargs) await self._service("swap", "start", quiet=True, **kwargs) await self._service("mountlate", "start", quiet=True, **kwargs) # Restarting collectd may take a long time and there is no # benefit in waiting for it since even if it fails it wont # tell the user anything useful. asyncio.ensure_future(self.restart("collectd", kwargs)) async def _reload_user(self, **kwargs): await self._service("ix-passwd", "start", quiet=True, **kwargs) await self._service("ix-aliases", "start", quiet=True, **kwargs) await self._service("ix-sudoers", "start", quiet=True, **kwargs) await self.reload("cifs", kwargs) async def _restart_system_datasets(self, **kwargs): systemdataset = await self.middleware.call('systemdataset.setup') if not systemdataset: return None if systemdataset['syslog']: await self.restart("syslogd", kwargs) await self.restart("cifs", kwargs) if systemdataset['rrd']: # Restarting collectd may take a long time and there is no # benefit in waiting for it since even if it fails it wont # tell the user anything useful. asyncio.ensure_future(self.restart("collectd", kwargs))
class MailService(ConfigService): oauth_access_token = None oauth_access_token_expires_at = None class Config: datastore = 'system.email' datastore_prefix = 'em_' datastore_extend = 'mail.mail_extend' cli_namespace = 'system.mail' ENTRY = Dict( 'mail_entry', Str('fromemail', validators=[Email()], required=True), Str('fromname', required=True), Str('outgoingserver', required=True), Int('port', required=True), Str('security', enum=['PLAIN', 'SSL', 'TLS'], required=True), Bool('smtp', required=True), Str('user', null=True, required=True), Str('pass', private=True, null=True, required=True), Dict( 'oauth', Str('client_id'), Str('client_secret'), Str('refresh_token'), null=True, private=True, required=True, ), Int('id', required=True), ) @private async def mail_extend(self, cfg): if cfg['security']: cfg['security'] = cfg['security'].upper() return cfg @accepts( Patch('mail_entry', 'mail_update', ('rm', { 'name': 'id' }), ('replace', Dict( 'oauth', Str('client_id', required=True), Str('client_secret', required=True), Str('refresh_token', required=True), null=True, private=True, )), ('attr', { 'update': True }), register=True)) async def do_update(self, data): """ Update Mail Service Configuration. `fromemail` is used as a sending address which the mail server will use for sending emails. `outgoingserver` is the hostname or IP address of SMTP server used for sending an email. `security` is type of encryption desired. `smtp` is a boolean value which when set indicates that SMTP authentication has been enabled and `user`/`pass` are required attributes now. """ config = await self.config() new = config.copy() new.update(data) new['security'] = new['security'].lower() # Django Model compatibility verrors = ValidationErrors() if new['smtp'] and new['user'] == '': verrors.add( 'mail_update.user', 'This field is required when SMTP authentication is enabled', ) if new['oauth']: if new['fromemail']: verrors.add('mail_update.fromemail', 'This field cannot be used with GMail') if new['fromname']: verrors.add('mail_update.fromname', 'This field cannot be used with GMail') self.__password_verify(new['pass'], 'mail_update.pass', verrors) if verrors: raise verrors await self.middleware.call('datastore.update', 'system.email', config['id'], new, {'prefix': 'em_'}) await self.middleware.call('mail.gmail_initialize') return await self.config() def __password_verify(self, password, schema, verrors=None): if not password: return if verrors is None: verrors = ValidationErrors() # FIXME: smtplib does not support non-ascii password yet # https://github.com/python/cpython/pull/8938 try: password.encode('ascii') except UnicodeEncodeError: verrors.add( schema, 'Only plain text characters (7-bit ASCII) are allowed in passwords. ' 'UTF or composed characters are not allowed.') return verrors @accepts( Dict( 'mail_message', Str('subject', required=True), Str('text', max_length=None), Str('html', null=True, max_length=None), List('to', items=[Str('email')]), List('cc', items=[Str('email')]), Int('interval', null=True), Str('channel', null=True), Int('timeout', default=300), Bool('attachments', default=False), Bool('queue', default=True), Dict('extra_headers', additional_attrs=True), register=True, ), Ref('mail_update')) @returns(Bool('successfully_sent')) @job(pipes=['input'], check_pipes=False) def send(self, job, message, config): """ Sends mail using configured mail settings. `text` will be formatted to HTML using Markdown and rendered using default E-Mail template. You can put your own HTML using `html`. If `html` is null, no HTML MIME part will be added to E-Mail. If `attachments` is true, a list compromised of the following dict is required via HTTP upload: - headers(list) - name(str) - value(str) - params(dict) - content (str) [ { "headers": [ { "name": "Content-Transfer-Encoding", "value": "base64" }, { "name": "Content-Type", "value": "application/octet-stream", "params": { "name": "test.txt" } } ], "content": "dGVzdAo=" } ] """ product_name = self.middleware.call_sync('system.product_name') gc = self.middleware.call_sync('datastore.config', 'network.globalconfiguration') hostname = f'{gc["gc_hostname"]}.{gc["gc_domain"]}' message['subject'] = f'{product_name} {hostname}: {message["subject"]}' add_html = True if 'html' in message and message['html'] is None: message.pop('html') add_html = False if 'text' not in message: if 'html' not in message: verrors = ValidationErrors() verrors.add('mail_message.text', 'Text is required when HTML is not set') verrors.check() message['text'] = html2text.html2text(message['html']) if add_html and 'html' not in message: lookup = TemplateLookup(directories=[ os.path.join(os.path.dirname(os.path.realpath(__file__)), '../assets/templates') ], module_directory="/tmp/mako/templates") tmpl = lookup.get_template('mail.html') message['html'] = tmpl.render( body=html.escape(message['text']).replace('\n', '<br>\n')) return self.send_raw(job, message, config) @accepts(Ref('mail_message'), Ref('mail_update')) @job(pipes=['input'], check_pipes=False) @private def send_raw(self, job, message, config): config = dict(self.middleware.call_sync('mail.config'), **config) if config['fromname']: from_addr = Header(config['fromname'], 'utf-8') try: config['fromemail'].encode('ascii') except UnicodeEncodeError: from_addr.append(f'<{config["fromemail"]}>', 'utf-8') else: from_addr.append(f'<{config["fromemail"]}>', 'ascii') else: try: config['fromemail'].encode('ascii') except UnicodeEncodeError: from_addr = Header(config['fromemail'], 'utf-8') else: from_addr = Header(config['fromemail'], 'ascii') interval = message.get('interval') if interval is None: interval = timedelta() else: interval = timedelta(seconds=interval) sw_name = self.middleware.call_sync('system.version').split('-', 1)[0] channel = message.get('channel') if not channel: channel = sw_name.lower() if interval > timedelta(): channelfile = '/tmp/.msg.%s' % (channel) last_update = datetime.now() - interval try: last_update = datetime.fromtimestamp( os.stat(channelfile).st_mtime) except OSError: pass timediff = datetime.now() - last_update if (timediff >= interval) or (timediff < timedelta()): # Make sure mtime is modified # We could use os.utime but this is simpler! with open(channelfile, 'w') as f: f.write('!') else: raise CallError( 'This message was already sent in the given interval') verrors = self.__password_verify(config['pass'], 'mail-config.pass') if verrors: raise verrors to = message.get('to') if not to: to = [ self.middleware.call_sync('user.query', [('username', '=', 'root')], {'get': True})['email'] ] if not to[0]: raise CallError('Email address for root is not configured') if message.get('attachments'): job.check_pipe("input") def read_json(): f = job.pipes.input.r data = b'' i = 0 while True: read = f.read(1048576) # 1MiB if read == b'': break data += read i += 1 if i > 50: raise ValueError( 'Attachments bigger than 50MB not allowed yet') if data == b'': return None return json.loads(data) attachments = read_json() else: attachments = None if 'html' in message or attachments: msg = MIMEMultipart() msg.preamble = 'This is a multi-part message in MIME format.' if 'html' in message: msg2 = MIMEMultipart('alternative') msg2.attach( MIMEText(message['text'], 'plain', _charset='utf-8')) msg2.attach(MIMEText(message['html'], 'html', _charset='utf-8')) msg.attach(msg2) if attachments: for attachment in attachments: m = Message() m.set_payload(attachment['content']) for header in attachment.get('headers'): m.add_header(header['name'], header['value'], **(header.get('params') or {})) msg.attach(m) else: msg = MIMEText(message['text'], _charset='utf-8') msg['Subject'] = message['subject'] msg['From'] = from_addr msg['To'] = ', '.join(to) if message.get('cc'): msg['Cc'] = ', '.join(message.get('cc')) msg['Date'] = formatdate() local_hostname = self.middleware.call_sync('system.hostname') msg['Message-ID'] = "<%s-%s.%s@%s>" % ( sw_name.lower(), datetime.utcnow().strftime("%Y%m%d.%H%M%S.%f"), base64.urlsafe_b64encode(os.urandom(3)), local_hostname) extra_headers = message.get('extra_headers') or {} for key, val in list(extra_headers.items()): # We already have "Content-Type: multipart/mixed" and setting "Content-Type: text/plain" like some scripts # do will break python e-mail module. if key.lower() == "content-type": continue if key in msg: msg.replace_header(key, val) else: msg[key] = val syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_MAIL) try: if config['oauth']: self.middleware.call_sync('mail.gmail_send', msg, config) else: server = self._get_smtp_server(config, message['timeout'], local_hostname=local_hostname) # NOTE: Don't do this. # # If smtplib.SMTP* tells you to run connect() first, it's because the # mailserver it tried connecting to via the outgoing server argument # was unreachable and it tried to connect to 'localhost' and barfed. # This is because FreeNAS doesn't run a full MTA. # else: # server.connect() headers = '\n'.join([f'{k}: {v}' for k, v in msg._headers]) syslog.syslog(f"sending mail to {', '.join(to)}\n{headers}") server.sendmail(from_addr.encode(), to, msg.as_string()) server.quit() except Exception as e: # Don't spam syslog with these messages. They should only end up in the # test-email pane. # We are only interested in ValueError, not subclasses. if e.__class__ is ValueError: raise CallError(str(e)) syslog.syslog(f'Failed to send email to {", ".join(to)}: {str(e)}') if isinstance(e, smtplib.SMTPAuthenticationError): raise CallError( f'Authentication error ({e.smtp_code}): {e.smtp_error}', errno.EAUTH if osc.IS_FREEBSD else errno.EPERM) self.logger.warn('Failed to send email: %s', str(e), exc_info=True) if message['queue']: with MailQueue() as mq: mq.append(msg) raise CallError(f'Failed to send email: {e}') return True def _get_smtp_server(self, config, timeout=300, local_hostname=None): self.middleware.call_sync('network.general.will_perform_activity', 'mail') if local_hostname is None: local_hostname = self.middleware.call_sync('system.hostname') if not config['outgoingserver'] or not config['port']: # See NOTE below. raise ValueError('you must provide an outgoing mailserver and mail' ' server port when sending mail') if config['security'] == 'SSL': server = smtplib.SMTP_SSL(config['outgoingserver'], config['port'], timeout=timeout, local_hostname=local_hostname) else: server = smtplib.SMTP(config['outgoingserver'], config['port'], timeout=timeout, local_hostname=local_hostname) if config['security'] == 'TLS': server.starttls() if config['smtp']: server.login(config['user'], config['pass']) return server @periodic(600, run_on_start=False) @private def send_mail_queue(self): with MailQueue() as mq: for queue in list(mq.queue): try: config = self.middleware.call_sync('mail.config') if config['oauth']: self.middleware.call_sync('mail.gmail_send', queue.message, config) else: server = self._get_smtp_server(config) server.sendmail(queue.message['From'].encode(), queue.message['To'].split(', '), queue.message.as_string()) server.quit() except Exception: self.logger.debug('Sending message from queue failed', exc_info=True) queue.attempts += 1 if queue.attempts >= mq.MAX_ATTEMPTS: mq.queue.remove(queue) else: mq.queue.remove(queue)
class VCenterService(ConfigService): PRIVATE_GROUP_NAME = 'iXSystems' class Config: datastore = 'vcp.vcenterconfiguration' datastore_prefix = 'vc_' datastore_extend = 'vcenter.vcenter_extend' @private async def common_validation(self, data, schema_name): verrors = ValidationErrors() ip = data.get('ip') if ip: await resolve_hostname(self.middleware, verrors, f'{schema_name}.ip', ip) management_ip = data.get('management_ip') if management_ip and management_ip not in ( await self.get_management_ip_choices()): verrors.add(f'{schema_name}.management_ip', 'Please select a valid IP for your TrueNAS system') action = data.get('action') if action and action != 'UNINSTALL': if (not (await self.middleware.call('vcenteraux.config'))['enable_https'] and (await self.middleware.call('system.general.config') )['ui_httpsredirect']): verrors.add(f'{schema_name}.action', 'Please enable vCenter plugin over HTTPS') return verrors @private async def vcenter_extend(self, data): data['password'] = await self.middleware.call('notifier.pwenc_decrypt', data['password']) data['port'] = int( data['port']) if data['port'] else 443 # Defaulting to 443 return data @accepts( Dict( 'vcenter_update_dict', Int('port', validators=[Port()]), Str('action', enum=['INSTALL', 'REPAIR', 'UNINSTALL', 'UPGRADE'], required=True), Str('management_ip'), Str('ip'), # HOST IP Str('password', password=True), Str('username'), )) async def do_update(self, data): old = await self.config() new = old.copy() new.update(data) schema_name = 'vcenter_update' verrors = await self.common_validation(new, schema_name) if verrors: raise verrors action = new.pop('action') system_general = await self.middleware.call('system.general.config') ui_protocol = 'https' if system_general['ui_httpsredirect'] else 'http' ui_port = system_general['ui_port'] if ui_protocol.lower( ) != 'https' else system_general['ui_httpsport'] fingerprint = await self.middleware.call( 'certificate.get_host_certificates_thumbprint', new['management_ip'], new['port']) plugin_file_name = await self.middleware.run_in_thread( self.get_plugin_file_name) # TODO: URL will change once the plugin file's location is shifted management_addr = f'{ui_protocol}://{new["management_ip"]}:{ui_port}/legacy/static/{plugin_file_name}' install_dict = { 'port': new['port'], 'fingerprint': fingerprint, 'management_ip': management_addr, 'ip': new['ip'], 'password': new['password'], 'username': new['username'] } if action == 'INSTALL': if new['installed']: verrors.add(f'{schema_name}.action', 'Plugin is already installed') else: for r_key in ('management_ip', 'ip', 'password', 'port', 'username'): if not new[r_key]: verrors.add( f'{schema_name}.{r_key}', 'This field is required to install the plugin') if verrors: raise verrors try: await self.middleware.run_in_thread( self.__install_vcenter_plugin, install_dict) except ValidationError as e: verrors.add_validation_error(e) else: new['version'] = await self.middleware.run_in_thread( self.get_plugin_version) new['installed'] = True elif action == 'REPAIR': if not new['installed']: verrors.add( f'{schema_name}.action', 'Plugin is not installed. Please install it first') else: # FROM MY UNDERSTANDING REPAIR IS CALLED WHEN THE DATABASE APPARENTLY TELLS THAT THE PLUGIN IS PRESENT # BUT THE SYSTEM FAILS TO RECOGNIZE THE PLUGIN EXTENSION try: credential_dict = install_dict.copy() credential_dict.pop('management_ip') credential_dict.pop('fingerprint') found_plugin = await self.middleware.run_in_thread( self._find_plugin, credential_dict) if found_plugin: verrors.add(f'{schema_name}.action', 'Plugin repair is not required') except ValidationError as e: verrors.add_validation_error(e) else: if verrors: raise verrors try: repair_dict = install_dict.copy() repair_dict['install_mode'] = 'REPAIR' await self.middleware.run_in_thread( self.__install_vcenter_plugin, repair_dict) except ValidationError as e: verrors.add_validation_error(e) elif action == 'UNINSTALL': if not new['installed']: verrors.add(f'{schema_name}.action', 'Plugin is not installed on the system') else: try: uninstall_dict = install_dict.copy() uninstall_dict.pop('management_ip') uninstall_dict.pop('fingerprint') await self.middleware.run_in_thread( self.__uninstall_vcenter_plugin, uninstall_dict) except ValidationError as e: verrors.add_validation_error(e) else: new['installed'] = False new['port'] = 443 for key in new: # flushing existing object with empty values if key not in ('installed', 'id', 'port'): new[key] = '' else: if not new['installed']: verrors.add(f'{schema_name}.action', 'Plugin not installed') elif not (await self.is_update_available()): verrors.add(f'{schema_name}.action', 'No update is available for vCenter plugin') else: try: await self.middleware.run_in_thread( self.__upgrade_vcenter_plugin, install_dict) except ValidationError as e: verrors.add_validation_error(e) else: new['version'] = await self.middleware.run_in_thread( self.get_plugin_version) if verrors: raise verrors new['password'] = await self.middleware.call('notifier.pwenc_encrypt', new['password']) await self.middleware.call('datastore.update', self._config.datastore, new['id'], new, {'prefix': self._config.datastore_prefix}) return await self.config() @private async def is_update_available(self): latest_version = await self.middleware.run_in_thread( self.get_plugin_version) current_version = (await self.config())['version'] return latest_version if current_version and \ parse_version(latest_version) > parse_version(current_version) else None @private async def plugin_root_path(self): return await self.middleware.call('notifier.gui_static_root') @private async def get_management_ip_choices(self): ip_list = await self.middleware.call('interfaces.ip_in_use', {'ipv4': True}) return [ip_dict['address'] for ip_dict in ip_list] @private def get_plugin_file_name(self): # TODO: The path to the plugin should be moved over to middlewared from django root_path = self.middleware.call_sync('vcenter.plugin_root_path') return next(v for v in os.listdir(root_path) if 'plugin' in v and '.zip' in v) @private def get_plugin_version(self): file_name = self.get_plugin_file_name() return file_name.split('_')[1] @private async def property_file_path(self): return os.path.join( (await self.middleware.call('notifier.gui_base_path')), 'vcp/Extensionconfig.ini.dist') @private async def resource_folder_path(self): return os.path.join( (await self.middleware.call('notifier.gui_base_path')), 'vcp/vcp_locales') @private def create_event_keyvalue_pairs(self): try: eri_list = [] resource_folder_path = self.middleware.call_sync( 'vcenter.resource_folder_path') for file in os.listdir(resource_folder_path): eri = vim.Extension.ResourceInfo() # Read locale file from vcp_locale eri.module = file.split("_")[0] with open(os.path.join(resource_folder_path, file), 'r') as file: for line in file: if len(line) > 2 and '=' in line: if 'locale' in line: eri.locale = line.split( '=')[1].lstrip().rstrip() else: prop = line.split('=') key_val = vim.KeyValue() key_val.key = prop[0].lstrip().rstrip() key_val.value = prop[1].lstrip().rstrip() eri.data.append(key_val) eri_list.append(eri) return eri_list except Exception as e: raise ValidationError('vcenter_update.create_event_keyvalue_pairs', f'Can not read locales : {e}') @private def get_extension_key(self): cp = configparser.ConfigParser() cp.read(self.middleware.call_sync('vcenter.property_file_path')) return cp.get('RegisterParam', 'key') @accepts( Dict( 'install_vcenter_plugin', Int('port', required=True), Str('fingerprint', required=True), Str('management_ip', required=True), Str('install_mode', enum=['NEW', 'REPAIR'], required=False, default='NEW'), Str('ip', required=True), # HOST IP Str('password', password=True, required=True), # Password should be decrypted Str('username', required=True), register=True)) def __install_vcenter_plugin(self, data): encrypted_password = self.middleware.call_sync( 'notifier.pwenc_encrypt', data['password']) update_zipfile_dict = data.copy() update_zipfile_dict.pop('management_ip') update_zipfile_dict.pop('fingerprint') update_zipfile_dict['password'] = encrypted_password update_zipfile_dict['plugin_version_old'] = 'null' update_zipfile_dict['plugin_version_new'] = self.get_plugin_version() self.__update_plugin_zipfile(update_zipfile_dict) data.pop('install_mode') try: ext = self.get_extension(data['management_ip'], data['fingerprint']) data.pop('fingerprint') data.pop('management_ip') si = self.__check_credentials(data) si.RetrieveServiceContent().extensionManager.RegisterExtension(ext) except vim.fault.NoPermission: raise ValidationError( 'vcenter_update.username', 'VCenter user has no permission to install the plugin') @accepts( Patch('install_vcenter_plugin', 'uninstall_vcenter_plugin', ('rm', { 'name': 'fingerprint' }), ('rm', { 'name': 'install_mode' }), ('rm', { 'name': 'management_ip' }), register=True)) def __uninstall_vcenter_plugin(self, data): try: extkey = self.get_extension_key() si = self.__check_credentials(data) si.RetrieveServiceContent().extensionManager.UnregisterExtension( extkey) except vim.fault.NoPermission: raise ValidationError( 'vcenter_update.username', 'VCenter user does not have necessary permission to uninstall the plugin' ) @accepts( Patch('install_vcenter_plugin', 'upgrade_vcenter_plugin', ('rm', { 'name': 'install_mode' }))) def __upgrade_vcenter_plugin(self, data): update_zipfile_dict = data.copy() update_zipfile_dict.pop('management_ip') update_zipfile_dict.pop('fingerprint') update_zipfile_dict['install_mode'] = 'UPGRADE' update_zipfile_dict['password'] = self.middleware.call_sync( 'notifier.pwenc_encrypt', data['password']) update_zipfile_dict['plugin_version_old'] = str( (self.middleware.call_sync('vcenter.config'))['version']) update_zipfile_dict['plugin_version_new'] = self.middleware.call_sync( 'vcenter.get_plugin_version') self.__update_plugin_zipfile(update_zipfile_dict) try: ext = self.get_extension(data['management_ip'], data['fingerprint']) data.pop('fingerprint') data.pop('management_ip') si = self.__check_credentials(data) si.RetrieveServiceContent().extensionManager.UpdateExtension(ext) except vim.fault.NoPermission: raise ValidationError( 'vcenter_update.username', 'VCenter user has no permission to upgrade the plugin') @accepts(Ref('uninstall_vcenter_plugin')) def _find_plugin(self, data): try: si = self.__check_credentials(data) extkey = self.get_extension_key() ext = si.RetrieveServiceContent().extensionManager.FindExtension( extkey) if ext is None: return False else: return f'TrueNAS System : {ext.client[0].url.split("/")[2]}' except vim.fault.NoPermission: raise ValidationError( 'vcenter_update.username', 'VCenter user has no permission to find the plugin on this system' ) @accepts(Ref('uninstall_vcenter_plugin')) def __check_credentials(self, data): try: si = SmartConnect("https", data['ip'], data['port'], data['username'], data['password'], sslContext=get_context_object()) if si: return si except (socket.gaierror, TimeoutError): raise ValidationError( 'vcenter_update.ip', 'Provided vCenter Hostname/IP or port are not valid') except vim.fault.InvalidLogin: raise ValidationError( 'vcenter_update.username', 'Provided vCenter credentials are not valid ( username or password )' ) except vim.fault.NoPermission: raise ValidationError( 'vcenter_update.username', 'vCenter user does not have permission to perform this operation' ) except Exception as e: if 'not a vim server' in str(e).lower(): # In case an IP is provided for a server which is not a VIM server - then Exception is raised with # following text # Exception: 10.XX.XX.XX:443 is not a VIM server raise ValidationError( 'vcenter_update.ip', 'Provided Hostname/IP is not a VIM server') else: raise e @private def get_extension(self, vcp_url, fingerprint): try: cp = configparser.ConfigParser() cp.read(self.middleware.call_sync('vcenter.property_file_path')) version = self.middleware.call_sync('vcenter.get_plugin_version') description = vim.Description() description.label = cp.get('RegisterParam', 'label') description.summary = cp.get('RegisterParam', 'description') ext = vim.Extension() ext.company = cp.get('RegisterParam', 'company') ext.version = version ext.key = cp.get('RegisterParam', 'key') ext.description = description ext.lastHeartbeatTime = datetime.now() server_info = vim.Extension.ServerInfo() server_info.serverThumbprint = fingerprint server_info.type = vcp_url.split(':')[0].upper() # sysgui protocol server_info.url = vcp_url server_info.description = description server_info.company = cp.get('RegisterParam', 'company') server_info.adminEmail = ['ADMIN EMAIL'] ext.server = [server_info] client = vim.Extension.ClientInfo() client.url = vcp_url client.company = cp.get('RegisterParam', 'company') client.version = version client.description = description client.type = "vsphere-client-serenity" ext.client = [client] event_info = [] for e in cp.get('RegisterParam', 'events').split(","): ext_event_type_info = vim.Extension.EventTypeInfo() ext_event_type_info.eventID = e event_info.append(ext_event_type_info) task_info = [] for t in cp.get('RegisterParam', 'tasks').split(","): ext_type_info = vim.Extension.TaskTypeInfo() ext_type_info.taskID = t task_info.append(ext_type_info) # Register custom privileges required for vcp RBAC priv_info = [] for priv in cp.get('RegisterParam', 'auth').split(","): ext_type_info = vim.Extension.PrivilegeInfo() ext_type_info.privID = priv ext_type_info.privGroupName = self.PRIVATE_GROUP_NAME priv_info.append(ext_type_info) ext.taskList = task_info ext.eventList = event_info ext.privilegeList = priv_info resource_list = self.create_event_keyvalue_pairs() ext.resourceList = resource_list return ext except configparser.NoOptionError as e: raise ValidationError('vcenter_update.get_extension', f'Property Missing : {e}') @private def extract_zip(self, src_path, dest_path): if not os.path.exists(dest_path): os.makedirs(dest_path) with zipfile.ZipFile(src_path) as zip_f: zip_f.extractall(dest_path) @private def zipdir(self, src_path, dest_path): assert os.path.isdir(src_path) with closing(zipfile.ZipFile(dest_path, "w")) as z: for root, dirs, files in os.walk(src_path): for fn in files: absfn = os.path.join(root, fn) zfn = absfn[len(src_path) + len(os.sep):] z.write(absfn, zfn) @private def remove_directory(self, dest_path): if os.path.exists(dest_path): shutil.rmtree(dest_path) @accepts( Dict( 'update_vcp_plugin_zipfile', Int('port', required=True), Str('ip', required=True, validators=[IpAddress()]), Str('install_mode', enum=['NEW', 'REPAIR', 'UPGRADE'], required=True), Str('plugin_version_old', required=True), Str('plugin_version_new', required=True), Str('password', required=True, password=True), # should be encrypted Str('username', required=True), register=True)) def __update_plugin_zipfile(self, data): file_name = self.middleware.call_sync('vcenter.get_plugin_file_name') plugin_root_path = self.middleware.call_sync( 'vcenter.plugin_root_path') self.extract_zip(os.path.join(plugin_root_path, file_name), os.path.join(plugin_root_path, 'plugin')) self.extract_zip( os.path.join(plugin_root_path, 'plugin/plugins/ixsystems-vcp-service.jar'), os.path.join(plugin_root_path, 'plugin/plugins/ixsystems-vcp-service')) data['fpath'] = os.path.join( plugin_root_path, 'plugin/plugins/ixsystems-vcp-service/META-INF/config/install.properties' ) self.__create_property_file(data) self.zipdir( os.path.join(plugin_root_path, 'plugin/plugins/ixsystems-vcp-service'), os.path.join(plugin_root_path, 'plugin/plugins/ixsystems-vcp-service.jar')) self.remove_directory( os.path.join(plugin_root_path, 'plugin/plugins/ixsystems-vcp-service')) shutil.make_archive(os.path.join(plugin_root_path, file_name[0:-4]), 'zip', os.path.join(plugin_root_path, 'plugin')) self.remove_directory(os.path.join(plugin_root_path, 'plugin')) @accepts( Patch( 'update_vcp_plugin_zipfile', '__create_property_file', ('add', { 'name': 'fpath', 'type': 'str' }), )) def __create_property_file(self, data): # Password encrypted using notifier.pwenc_encrypt config = configparser.ConfigParser() with open(data['fpath'], 'w') as config_file: config.add_section('installation_parameter') config.set('installation_parameter', 'ip', data['ip']) config.set('installation_parameter', 'username', data['username']) config.set('installation_parameter', 'port', str(data['port'])) config.set('installation_parameter', 'password', data['password']) config.set('installation_parameter', 'install_mode', data['install_mode']) config.set('installation_parameter', 'plugin_version_old', data['plugin_version_old']) config.set('installation_parameter', 'plugin_version_new', data['plugin_version_new']) config.write(config_file)
class ReportingService(ConfigService): class Config: datastore = 'system.reporting' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__rrds = {} for name, klass in RRD_PLUGINS.items(): self.__rrds[name] = klass(self.middleware) @accepts( Dict('reporting_update', Bool('cpu_in_percentage'), Str('graphite'), Int('graph_age', validators=[Range(min=1)]), Int('graph_points', validators=[Range(min=1)]), Bool('confirm_rrd_destroy'), update=True)) async def do_update(self, data): """ Configure Reporting Database settings. If `cpu_in_percentage` is `true`, collectd reports CPU usage in percentage instead of "jiffies". `graphite` specifies a destination hostname or IP for collectd data sent by the Graphite plugin.. `graph_age` specifies the maximum age of stored graphs in months. `graph_points` is the number of points for each hourly, daily, weekly, etc. graph. Changing these requires destroying the current reporting database, so when these fields are changed, an additional `confirm_rrd_destroy: true` flag must be present. .. examples(websocket):: Update reporting settings :::javascript { "id": "6841f242-840a-11e6-a437-00e04d680384", "msg": "method", "method": "reporting.update", "params": [{ "cpu_in_percentage": false, "graphite": "", }] } Recreate reporting database with new settings :::javascript { "id": "6841f242-840a-11e6-a437-00e04d680384", "msg": "method", "method": "reporting.update", "params": [{ "graph_age": 12, "graph_points": 1200, "confirm_rrd_destroy": true, }] } """ confirm_rrd_destroy = data.pop('confirm_rrd_destroy', False) old = await self.config() new = copy.deepcopy(old) new.update(data) verrors = ValidationErrors() destroy_database = False for k in ['graph_age', 'graph_points']: if old[k] != new[k]: destroy_database = True if not confirm_rrd_destroy: verrors.add( f'reporting_update.{k}', _('Changing this option requires destroying the reporting database. This action must be ' 'confirmed by setting confirm_rrd_destroy flag'), ) if verrors: raise verrors await self.middleware.call('datastore.update', self._config.datastore, old['id'], new, {'prefix': self._config.datastore_prefix}) if destroy_database: await self.middleware.call('service.stop', 'collectd') await self.middleware.call('service.stop', 'rrdcached') await run('sh', '-c', 'rm -rfx /var/db/collectd/rrd/*', check=False) await self.middleware.call('reporting.setup') await self.middleware.call('service.start', 'rrdcached') await self.middleware.call('service.restart', 'collectd') return await self.config() @private def setup(self): systemdatasetconfig = self.middleware.call_sync('systemdataset.config') if not systemdatasetconfig['path']: self.middleware.logger.error(f'System dataset is not mounted') return False rrd_mount = f'{systemdatasetconfig["path"]}/rrd-{systemdatasetconfig["uuid"]}' if not os.path.exists(rrd_mount): self.middleware.logger.error( f'{rrd_mount} does not exist or is not a directory') return False # Ensure that collectd working path is a symlink to system dataset pwd = '/var/db/collectd/rrd' if os.path.exists(pwd) and (not os.path.isdir(pwd) or not os.path.islink(pwd)): shutil.move(pwd, f'{pwd}.{time.strftime("%Y%m%d%H%M%S")}') if not os.path.exists(pwd): os.symlink(rrd_mount, pwd) # Migrate legacy RAMDisk persist_file = '/data/rrd_dir.tar.bz2' if os.path.isfile(persist_file): with tarfile.open(persist_file) as tar: if 'collectd/rrd' in tar.getnames(): tar.extractall(pwd, get_members(tar, 'collectd/rrd/')) os.unlink('/data/rrd_dir.tar.bz2') hostname = self.middleware.call_sync('system.info')['hostname'] if not hostname: hostname = self.middleware.call_sync( 'network.configuration.config')['hostname_local'] # Migrate from old version, where `hostname` was a real directory and `localhost` was a symlink. # Skip the case where `hostname` is "localhost", so symlink was not (and is not) needed. if (hostname != 'localhost' and os.path.isdir(os.path.join(pwd, hostname)) and not os.path.islink(os.path.join(pwd, hostname))): if os.path.exists(os.path.join(pwd, 'localhost')): if os.path.islink(os.path.join(pwd, 'localhost')): os.unlink(os.path.join(pwd, 'localhost')) else: # This should not happen, but just in case shutil.move( os.path.join(pwd, 'localhost'), os.path.join( pwd, f'localhost.bak.{time.strftime("%Y%m%d%H%M%S")}')) shutil.move(os.path.join(pwd, hostname), os.path.join(pwd, 'localhost')) # Remove all directories except "localhost" and its backups (that may be erroneously created by # running collectd before this script) to_remove_dirs = [ os.path.join(pwd, d) for d in os.listdir(pwd) if not d.startswith('localhost') and os.path.isdir(os.path.join(pwd, d)) ] for r_dir in to_remove_dirs: subprocess.run(['rm', '-rfx', r_dir]) # Remove all symlinks (that are stale if hostname was changed). to_remove_symlinks = [ os.path.join(pwd, l) for l in os.listdir(pwd) if os.path.islink(os.path.join(pwd, l)) ] for r_symlink in to_remove_symlinks: os.unlink(r_symlink) # Create "localhost" directory if it does not exist if not os.path.exists(os.path.join(pwd, 'localhost')): os.makedirs(os.path.join(pwd, 'localhost')) # Create "${hostname}" -> "localhost" symlink if necessary if hostname != 'localhost': os.symlink(os.path.join(pwd, 'localhost'), os.path.join(pwd, hostname)) # Let's return a positive value to indicate that necessary collectd operations were performed successfully return True @filterable def graphs(self, filters, options): return filter_list( [i.__getstate__() for i in self.__rrds.values() if i.has_data()], filters, options) def __rquery_to_start_end(self, query): unit = query.get('unit') if unit: verrors = ValidationErrors() for i in ('start', 'end'): if i in query: verrors.add( f'reporting_query.{i}', f'{i!r} should only be used if "unit" attribute is not provided.', ) verrors.check() else: if 'start' not in query: unit = 'HOURLY' else: starttime = query['start'] endtime = query.get('end') or 'now' if unit: unit = unit[0].lower() page = query['page'] starttime = f'end-{page + 1}{unit}' if not page: endtime = 'now' else: endtime = f'now-{page}{unit}' return starttime, endtime @accepts( List('graphs', items=[ Dict( 'graph', Str('name', required=True), Str('identifier', default=None, null=True), ), ], empty=False), Dict( 'reporting_query', Str('unit', enum=['HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR']), Int('page', default=0), Str('start', empty=False), Str('end', empty=False), Bool('aggregate', default=True), register=True, )) def get_data(self, graphs, query): """ Get reporting data for given graphs. List of possible graphs can be retrieved using `reporting.graphs` call. For the time period of the graph either `unit` and `page` OR `start` and `end` should be used, not both. `aggregate` will return aggregate available data for each graph (e.g. min, max, mean). .. examples(websocket):: Get graph data of "nfsstat" from the last hour. :::javascript { "id": "6841f242-840a-11e6-a437-00e04d680384", "msg": "method", "method": "reporting.get_data", "params": [ [{"name": "nfsstat"}], {"unit": "HOURLY"}, ] } """ starttime, endtime = self.__rquery_to_start_end(query) rv = [] for i in graphs: try: rrd = self.__rrds[i['name']] except KeyError: raise CallError(f'Graph {i["name"]!r} not found.', errno.ENOENT) rv.append( rrd.export(i['identifier'], starttime, endtime, aggregate=query['aggregate'])) return rv @private @accepts(Ref('reporting_query')) def get_all(self, query): starttime, endtime = self.__rquery_to_start_end(query) rv = [] for rrd in self.__rrds.values(): idents = rrd.get_identifiers() if idents is None: idents = [None] for ident in idents: rv.append( rrd.export(ident, starttime, endtime, aggregate=query['aggregate'])) return rv
def filterable(fn): fn._filterable = True return accepts(Ref('query-filters'), Ref('query-options'))(fn)
class DatastoreService(Service, FilterMixin, SchemaMixin): class Config: private = True @accepts( Str('name'), List('query-filters', register=True), Dict( 'query-options', Bool('relationships', default=True), Str('extend', default=None, null=True), Str('extend_context', default=None, null=True), Str('prefix', default=None, null=True), Dict('extra', additional_attrs=True), List('order_by'), List('select'), Bool('count', default=False), Bool('get', default=False), Int('offset', default=0), Int('limit', default=0), Bool('force_sql_filters', default=False), register=True, ), ) async def query(self, name, filters, options): """ Query for items in a given collection `name`. `filters` is a list which each entry can be in one of the following formats: entry: simple_filter | conjuntion simple_filter: '[' attribute_name, OPERATOR, value ']' conjunction: '[' CONJUNCTION, '[' simple_filter (',' simple_filter)* ']]' OPERATOR: ('=' | '!=' | '>' | '>=' | '<' | '<=' | '~' | 'in' | 'nin') CONJUNCTION: 'OR' e.g. `['OR', [ ['username', '=', 'root' ], ['uid', '=', 0] ] ]` `[ ['username', '=', 'root' ] ]` .. examples(websocket):: Querying for username "root" and returning a single item: :::javascript { "id": "d51da71b-bb48-4b8b-a8f7-6046fcc892b4", "msg": "method", "method": "datastore.query", "params": ["account.bsdusers", [ ["username", "=", "root" ] ], {"get": true}] } """ table = self._get_table(name) # We do not want to make changes to original options # which might happen with "prefix" options = options.copy() aliases = {} if options['count']: qs = select([func.count(self._get_pk(table))]) else: columns = list(table.c) from_ = table if options['relationships']: aliases = self._get_queryset_joins(table) for foreign_key, alias in aliases.items(): columns.extend(list(alias.c)) from_ = from_.outerjoin(alias, alias.c[foreign_key.column.name] == foreign_key.parent) qs = select(columns).select_from(from_) prefix = options['prefix'] if filters: qs = qs.where(and_(*self._filters_to_queryset(filters, table, prefix, aliases))) if options['count']: return (await self.middleware.call("datastore.fetchall", qs))[0][0] order_by = options['order_by'] if order_by: # Do not change original order_by order_by = order_by[:] for i, order in enumerate(order_by): if order.startswith('nulls_first:'): wrapper = nullsfirst order = order[len('nulls_first:'):] elif order.startswith('nulls_last:'): wrapper = nullslast order = order[len('nulls_last:'):] else: wrapper = lambda x: x # noqa if order.startswith('-'): order_by[i] = self._get_col(table, order[1:], prefix).desc() else: order_by[i] = self._get_col(table, order, prefix) order_by[i] = wrapper(order_by[i]) # FIXME: remove this after switching to SQLite 3.30 changed = True while changed: changed = False for i, v in enumerate(order_by): if isinstance(v, UnaryExpression) and v.modifier in (nullsfirst_op, nullslast_op): if isinstance(v.element, UnaryExpression) and v.element.modifier == desc_op: root_element = v.element.element else: root_element = v.element order_by = order_by[:i] + [ { nullsfirst_op: root_element != None, # noqa nullslast_op: root_element == None, # noqa }[v.modifier], v.element, ] + order_by[i + 1:] changed = True break qs = qs.order_by(*order_by) if options['offset']: qs = qs.offset(options['offset']) if options['limit']: qs = qs.limit(options['limit']) result = await self.middleware.call("datastore.fetchall", qs) relationships = [{} for row in result] if options['relationships']: # This will only fetch many-to-many relationships for primary table, not for joins, but that's enough relationships = await self._fetch_many_to_many(table, result) result = await self._queryset_serialize( result, table, aliases, relationships, options['extend'], options['extend_context'], options['prefix'], options['select'], options['extra'], ) if options['get']: try: return result[0] except IndexError: raise MatchNotFound() from None return result @accepts(Str('name'), Ref('query-options')) async def config(self, name, options): """ Get configuration settings object for a given `name`. This is a shortcut for `query(name, {"get": true})`. """ options['get'] = True return await self.query(name, [], options) def _get_queryset_joins(self, table): result = {} for column in table.c: if column.foreign_keys: if len(column.foreign_keys) > 1: raise RuntimeError('Multiple foreign keys are not supported') foreign_key = list(column.foreign_keys)[0] alias = foreign_key.column.table.alias(foreign_key.name) result[foreign_key] = alias if foreign_key.column.table != (table.original if isinstance(table, Alias) else table): result.update(self._get_queryset_joins(alias)) return result async def _queryset_serialize( self, qs, table, aliases, relationships, extend, extend_context, field_prefix, select, extra_options, ): rows = [] for i, row in enumerate(qs): rows.append(self._serialize(row, table, aliases, relationships[i], field_prefix)) if extend_context: extend_context_value = await self.middleware.call(extend_context, rows, extra_options) else: extend_context_value = None return [ await self._extend(data, extend, extend_context, extend_context_value, select) for data in rows ] def _serialize(self, obj, table, aliases, relationships, field_prefix): data = self._serialize_row(obj, table, aliases) data.update(relationships) return {self._strip_prefix(k, field_prefix): v for k, v in data.items()} async def _extend(self, data, extend, extend_context, extend_context_value, select): if extend: if extend_context: data = await self.middleware.call(extend, data, extend_context_value) else: data = await self.middleware.call(extend, data) if not select: return data else: return {k: v for k, v in data.items() if k in select} def _strip_prefix(self, k, field_prefix): return k[len(field_prefix):] if field_prefix and k.startswith(field_prefix) else k def _serialize_row(self, obj, table, aliases): data = {} for column in table.c: # aliases == {} when we are loading without relationships, let's leave fk values in that case if not column.foreign_keys or not aliases: data[str(column.name)] = obj[column] for foreign_key, alias in aliases.items(): column = foreign_key.parent if column.table != table: continue if not column.name.endswith('_id'): raise RuntimeError('Foreign key column must end with _id') data[column.name[:-3]] = ( self._serialize_row(obj, alias, aliases) if obj[column] is not None and obj[self._get_pk(alias)] is not None else None ) return data async def _fetch_many_to_many(self, table, rows): pk = self._get_pk(table) pk_values = [row[pk] for row in rows] relationships = [{} for row in rows] if pk_values: for relationship_name, relationship in self._get_relationships(table).items(): # We can only join by single primary key assert len(relationship.synchronize_pairs) == 1 assert len(relationship.secondary_synchronize_pairs) == 1 local_pk, relationship_local_pk = relationship.synchronize_pairs[0] remote_pk, relationship_remote_pk = relationship.secondary_synchronize_pairs[0] assert local_pk == pk all_children_ids = set() pk_to_children_ids = defaultdict(set) for connection in await self.query( relationship.secondary.name.replace('_', '.', 1), [[relationship_local_pk.name, 'in', pk_values]], {'relationships': False} ): child_id = connection[relationship_remote_pk.name] all_children_ids.add(child_id) pk_to_children_ids[connection[relationship_local_pk.name]].add(child_id) all_children = {} if all_children_ids: for child in await self.query( relationship.target.name.replace('_', '.', 1), [[remote_pk.name, 'in', all_children_ids]], ): all_children[child[remote_pk.name]] = child for i, row in enumerate(rows): relationships[i][relationship_name] = [ all_children[child_id] for child_id in pk_to_children_ids[row[pk]] if child_id in all_children ] return relationships
class DSCache(Service): class Config: private = True @accepts( Str('directory_service', required=True, enum=["ACTIVEDIRECTORY", "LDAP"]), Str('idtype', enum=['USER', 'GROUP'], required=True), Dict('cache_entry', additional_attrs=True), ) async def insert(self, ds, idtype, entry): if idtype == "GROUP": id_key = "gid" name_key = "name" else: id_key = "uid" name_key = "username" ops = [{ "action": "SET", "key": f'ID_{entry[id_key]}', "val": entry }, { "action": "SET", "key": f'NAME_{entry[name_key]}', "val": entry }] await self.middleware.call('tdb.batch_ops', { "name": f'{ds.lower()}_{idtype.lower()}', "ops": ops }) return True @accepts( Str('directory_service', required=True, enum=["ACTIVEDIRECTORY", "LDAP"]), Dict( 'principal_info', Str('idtype', enum=['USER', 'GROUP']), Str('who'), Int('id'), ), Dict('options', Bool('synthesize', default=False))) async def retrieve(self, ds, data, options): who_str = data.get('who') who_id = data.get('id') if who_str is None and who_id is None: raise CallError("`who` or `id` entry is required to uniquely " "identify the entry to be retrieved.") tdb_name = f'{ds.lower()}_{data["idtype"].lower()}' prefix = "NAME" if who_str else "ID" tdb_key = f'{prefix}_{who_str if who_str else who_id}' try: entry = await self.middleware.call("tdb.fetch", { "name": tdb_name, "key": tdb_key }) except MatchNotFound: entry = None if not entry and options['synthesize']: """ if cache lacks entry, create one from passwd / grp info, insert into cache and return synthesized value. get_uncached_* will raise KeyError if NSS lookup fails. """ try: if data['idtype'] == 'USER': pwdobj = await self.middleware.call( 'dscache.get_uncached_user', who_str, who_id) entry = await self.middleware.call('idmap.synthetic_user', ds.lower(), pwdobj) else: grpobj = await self.middleware.call( 'dscache.get_uncached_group', who_str, who_id) entry = await self.middleware.call('idmap.synthetic_group', ds.lower(), grpobj) await self.insert(ds, data['idtype'], entry) except KeyError: entry = None elif not entry: raise KeyError(who_str if who_str else who_id) return entry @accepts( Str('ds', required=True, enum=["ACTIVEDIRECTORY", "LDAP"]), Str('idtype', required=True, enum=["USER", "GROUP"]), ) async def entries(self, ds, idtype): entries = await self.middleware.call( 'tdb.entries', { 'name': f'{ds.lower()}_{idtype.lower()}', 'query-filters': [('key', '^', 'ID')] }) return [x['val'] for x in entries] def get_uncached_user(self, username=None, uid=None): """ Returns dictionary containing pwd_struct data for the specified user or uid. Will raise an exception if the user does not exist. This method is appropriate for user validation. """ if username: u = pwd.getpwnam(username) elif uid is not None: u = pwd.getpwuid(uid) else: return {} return { 'pw_name': u.pw_name, 'pw_uid': u.pw_uid, 'pw_gid': u.pw_gid, 'pw_gecos': u.pw_gecos, 'pw_dir': u.pw_dir, 'pw_shell': u.pw_shell } def get_uncached_group(self, groupname=None, gid=None): """ Returns dictionary containing grp_struct data for the specified group or gid. Will raise an exception if the group does not exist. This method is appropriate for group validation. """ if groupname: g = grp.getgrnam(groupname) elif gid is not None: g = grp.getgrgid(gid) else: return {} return {'gr_name': g.gr_name, 'gr_gid': g.gr_gid, 'gr_mem': g.gr_mem} @accepts( Str('objtype', enum=['USERS', 'GROUPS'], default='USERS'), Ref('query-filters'), Ref('query-options'), ) async def query(self, objtype, filters, options): """ Query User / Group cache with `query-filters` and `query-options`. `objtype`: 'USERS' or 'GROUPS' """ res = [] ds_state = await self.middleware.call('directoryservices.get_state') enabled_ds = None is_name_check = bool(filters and len(filters) == 1 and filters[0][0] in ['username', 'name']) is_id_check = bool(filters and len(filters) == 1 and filters[0][0] in ['uid', 'gid']) res.extend((await self.middleware.call(f'{objtype.lower()[:-1]}.query', filters, options))) for dstype, state in ds_state.items(): if state != 'DISABLED': enabled_ds = dstype break if not enabled_ds: return res if is_name_check and filters[0][1] == '=': # exists in local sqlite database, return results if res: return res entry = await self.retrieve(enabled_ds.upper(), { 'idtype': objtype[:-1], 'who': filters[0][2], }, {'synthensize': True}) return [entry] if entry else [] if is_id_check and filters[0][1] == '=': # exists in local sqlite database, return results if res: return res entry = await self.retrieve(enabled_ds.upper(), { 'idtype': objtype[:-1], 'id': filters[0][2], }, {'synthesize': True}) return [entry] if entry else [] entries = await self.entries(enabled_ds.upper(), objtype[:-1]) entries_by_id = sorted(entries, key=lambda i: i['id']) res.extend(filter_list(entries_by_id, filters, options)) return res @job(lock="dscache_refresh") async def refresh(self, job): """ This is called from a cronjob every 24 hours and when a user clicks on the UI button to 'rebuild directory service cache'. """ for ds in ['activedirectory', 'ldap']: await self.middleware.call('tdb.wipe', {'name': f'{ds}_user'}) await self.middleware.call('tdb.wipe', {'name': f'{ds}_group'}) ds_state = await self.middleware.call(f'{ds}.get_state') if ds_state == 'HEALTHY': await job.wrap(await self.middleware.call(f'{ds}.fill_cache', True)) elif ds_state != 'DISABLED': self.logger.debug( 'Unable to refresh [%s] cache, state is: %s' % (ds, ds_state))
class TDBService(Service, TDBMixin, SchemaMixin): handles = {} class Config: private = True @private def validate_tdb_options(self, name, options): if options['service_version']['major'] > 0 or options[ 'service_version']['minor'] > 0: if options['tdb_type'] == 'BASIC': raise CallError( f"{name}: BASIC tdb types do not support versioning", errno.EINVAL) if not options['cluster']: return healthy = self.middleware.call_sync('ctdb.general.healthy') if healthy: return raise CallError(f"{name}: ctdb must be enabled and healthy.", errno.ENXIO) @private def _ctdb_get_dbid(self, name, options): dbmap = self.middleware.call_sync("ctdb.general.getdbmap", [("name", "=", f'{name}.tdb')]) if dbmap: return dbmap[0]['dbid'] cmd = ["ctdb", "attach", name, "persistent"] attach = run(cmd, check=False) if attach.returncode != 0: raise CallError("Failed to attach backend: %s", attach.stderr.decode()) dbmap = self.middleware.call_sync("ctdb.general.getdbmap", [("name", "=", f'{name}.tdb')]) if not dbmap: raise CallError(f'{name}: failed to attach to database') return dbmap[0]['dbid'] @private def get_connection(self, name, options): self.validate_tdb_options(name, options) existing = self.handles.get('name') if existing: if options != existing['options']: raise CallError( f'{name}: Internal Error - tdb options mismatch', errno.EINVAL) if existing['handle']: return existing['handle'] else: self.handles[name] = {'name': name, 'options': options.copy()} if options['cluster']: dbid = self._ctdb_get_dbid(name, options) handle = self._get_handle(name, dbid, options) self.handles[name].update({'handle': handle}) else: handle = self._get_handle(name, None, options) return handle @accepts( Dict( 'tdb-store', Str('name', required=True), Str('key', required=True), Dict('value', required=True, additional_attrs=True), Dict('tdb-options', Str('backend', enum=['PERSISTENT', 'VOLATILE', 'CUSTOM'], default='PERSISTENT'), Str('tdb_type', enum=['BASIC', 'CRUD', 'CONFIG'], default='BASIC'), Str('data_type', enum=['JSON', 'STRING', 'BYTES'], default='JSON'), Bool('cluster', default=False), Int('read_backoff', default=0), Dict('service_version', Int('major', default=0), Int('minor', default=0)), register=True))) def store(self, data): handle = self.get_connection(data['name'], data['tdb-options']) if data['tdb-options']['data_type'] == 'JSON': tdb_val = json.dumps(data['value']) elif data['tdb-options']['data_type'] == 'STRING': tdb_val = data['value']['payload'] elif data['tdb-options']['data_type'] == 'BYTES': tdb_val = b64decode(data['value']['payload']) with closing(handle) as tdb_handle: self._set(tdb_handle, data['key'], tdb_val) @accepts( Dict( 'tdb-fetch', Str('name', required=True), Str('key', required=True), Ref('tdb-options'), )) def fetch(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: tdb_val = self._get(tdb_handle, data['key']) if tdb_val is None: raise MatchNotFound(data['key']) if data['tdb-options']['data_type'] == 'JSON': data = json.loads(tdb_val) elif data['tdb-options']['data_type'] == 'STRING': data = tdb_val elif data['tdb-options']['data_type'] == 'BYTES': data = b64encode(tdb_val).decode() return data @accepts( Dict( 'tdb-remove', Str('name', required=True), Str('key', required=True), Ref('tdb-options'), )) def remove(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: self._rem(tdb_handle, data['key']) @accepts( Dict( Str('name', required=True), Ref('query-filters'), Ref('query-options'), Ref('tdb-options'), )) def entries(self, data): def append_entries(tdb_key, tdb_data, state): if tdb_data is None: return True if state['data_type'] == 'JSON': entry = json.loads(tdb_data) elif state['data_type'] == 'STRING': entry = tdb_data elif state['data_type'] == 'BYTES': entry = b64encode(tdb_data) state['output'].append({"key": tdb_key, "val": entry}) return True state = {'output': [], 'data_type': data['tdb-options']['data_type']} handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: self._traverse(tdb_handle, append_entries, state) return filter_list(state['output'], data['query-filters'], data['query-options']) @accepts( Dict( 'tdb-batch-ops', Str('name', required=True), List('ops', required=True), Ref('tdb-options'), )) def batch_ops(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: data = self._batch_ops(tdb_handle, data['ops']) return data @accepts(Dict( 'tdb-wipe', Str('name', required=True), Ref('tdb-options'), )) def wipe(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: self._wipe(tdb_handle) @accepts( Dict( 'tdb-config-config', Str('name', required=True), Ref('tdb-options'), )) def config(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: data = self._config_config(tdb_handle) return data @accepts( Dict( 'tdb-config-update', Str('name', required=True), Dict('payload', additional_attrs=True), Ref('tdb-options'), )) def config_update(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: self._config_update(tdb_handle, data['payload']) return @accepts( Dict( 'tdb-crud-create', Str('name', required=True), Dict('payload', additional_attrs=True), Ref('tdb-options'), )) def create(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: id = self._create(tdb_handle, data['payload']) return id @accepts( Dict( 'tdb-crud-query', Str('name', required=True), Ref('query-filters'), Ref('query-options'), Ref('tdb-options'), )) def query(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: data = self._query(tdb_handle, data['query-filters'], data['query-options']) return data @accepts( Dict( 'tdb-crud-update', Str('name', required=True), Int('id', required=True), Dict('payload', additional_attrs=True), Ref('tdb-options'), )) def update(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: id = self._update(tdb_handle, data['id'], data['payload']) return id @accepts( Dict( 'tdb-crud-delete', Str('name', required=True), Int('id', required=True), Ref('tdb-options'), )) def delete(self, data): handle = self.get_connection(data['name'], data['tdb-options']) with closing(handle) as tdb_handle: self._delete(tdb_handle, data['id']) return @accepts( Dict( 'tdb-upgrade', Str('name', required=True), Ref('tdb-options'), )) def apply_upgrades(self, data): raise NotImplementedError @accepts() def show_handles(self): ret = {h['name']: h['options'] for h in self.handles.values()} return ret @private async def setup(self): for p in TDBPath: if p is TDBPath.CUSTOM: continue os.makedirs(p.value, mode=0o700, exist_ok=True)