diff --git a/confluent_client/bin/nodepower b/confluent_client/bin/nodepower index b21314b3..ccbf41bf 100755 --- a/confluent_client/bin/nodepower +++ b/confluent_client/bin/nodepower @@ -33,7 +33,7 @@ import confluent.client as client argparser = optparse.OptionParser( usage="Usage: %prog [options] " - "([status|on|off|shutdown|boot|reset])") + "([status|on|off|shutdown|boot|reset|pdu_status|pdu_off|pdu_on])") argparser.add_option('-p', '--showprevious', dest='previous', action='store_true', default=False, help='Show previous power state') @@ -51,22 +51,27 @@ except IndexError: client.check_globbing(noderange) setstate = None if len(args) > 1: + setstate = args[1] if setstate == 'softoff': setstate = 'shutdown' - elif not args[1] in ('stat', 'state', 'status'): - setstate = args[1] -if setstate not in (None, 'on', 'off', 'shutdown', 'boot', 'reset'): +if setstate not in (None, 'on', 'off', 'shutdown', 'boot', 'reset', 'pdu_status', 'pdu_stat', 'pdu_on', 'pdu_off', 'status', 'stat', 'state'): argparser.print_help() sys.exit(1) session = client.Command() exitcode = 0 session.add_precede_key('oldstate') +powurl = 'state' +if setstate and setstate.startswith('pdu_'): + setstate = setstate.replace('pdu_', '') + powurl = 'inlets/all' +if setstate in ('status', 'state', 'stat'): + setstate = None if options.previous: # get previous states prev = {} - for rsp in session.read("/noderange/{0}/power/state".format(noderange)): + for rsp in session.read("/noderange/{0}/power/{1}".format(noderange, powurl)): # gets previous (current) states databynode = rsp["databynode"] @@ -77,4 +82,7 @@ if options.previous: # add dictionary to session session.add_precede_dict(prev) -sys.exit(session.simple_noderange_command(noderange, '/power/state', setstate, promptover=options.maxnodes)) +def outhandler(node, res): + for k in res[node]: + client.cprint('{0}: {1}: {2}'.format(node, k.replace('inlet_', ''), res[node][k])) +sys.exit(session.simple_noderange_command(noderange, '/power/{0}'.format(powurl), setstate, promptover=options.maxnodes, key='state', outhandler=outhandler)) diff --git a/confluent_client/confluent/client.py b/confluent_client/confluent/client.py index bee16f00..ad29ff02 100644 --- a/confluent_client/confluent/client.py +++ b/confluent_client/confluent/client.py @@ -208,7 +208,7 @@ class Command(object): def add_precede_dict(self, dict): self._prevdict = dict - def handle_results(self, ikey, rc, res, errnodes=None): + def handle_results(self, ikey, rc, res, errnodes=None, outhandler=None): if 'error' in res: if errnodes is not None: errnodes.add(self._currnoderange) @@ -245,10 +245,12 @@ class Command(object): node, val, self._prevdict[node])) else: cprint('{0}: {1}'.format(node, val)) + elif outhandler: + outhandler(node, res) return rc def simple_noderange_command(self, noderange, resource, input=None, - key=None, errnodes=None, promptover=None, **kwargs): + key=None, errnodes=None, promptover=None, outhandler=None, **kwargs): try: self._currnoderange = noderange rc = 0 @@ -262,13 +264,13 @@ class Command(object): if input is None: for res in self.read('/noderange/{0}/{1}'.format( noderange, resource)): - rc = self.handle_results(ikey, rc, res, errnodes) + rc = self.handle_results(ikey, rc, res, errnodes, outhandler) else: self.stop_if_noderange_over(noderange, promptover) kwargs[ikey] = input for res in self.update('/noderange/{0}/{1}'.format( noderange, resource), kwargs): - rc = self.handle_results(ikey, rc, res, errnodes) + rc = self.handle_results(ikey, rc, res, errnodes, outhandler) self._currnoderange = None return rc except KeyboardInterrupt: diff --git a/confluent_client/doc/man/nodeping.ronn b/confluent_client/doc/man/nodeping.ronn new file mode 100644 index 00000000..3a1a6a37 --- /dev/null +++ b/confluent_client/doc/man/nodeping.ronn @@ -0,0 +1,38 @@ +nodeping(8) -- Pings a node or a noderange. +============================== +## SYNOPSIS +`nodeping [options] noderange` + +## DESCRIPTION +**nodeping** is a command that pings the default NIC on a node. +It can also be used with the `-s` flag to change the ping location to something that is 'non primary' + + +## OPTIONS +* ` -f` COUNT, `-c` COUNT, --count=COUNT + Number of commands to run at a time +* `-h`, `--help`: + Show help message and exit +* `-s` SUBSTITUTENAME, --substitutename=SUBSTITUTENAME + Use a different name other than the nodename for ping + +## EXAMPLES + * Pinging a node : + `# nodeping ` + `node : ping` + +* Pinging a group: + `# nodeping ` + `Node1 : ping + Node2 : ping + Node3 : ping` + +* Pinging BMC on a node: + `# nodeping -s {bmc} ` + ` Node-bmc : ping` + +* Fail to ping node: + `# nodeping ` + `node : no_ping` + + diff --git a/confluent_client/doc/man/nodepower.ronn b/confluent_client/doc/man/nodepower.ronn index e2d90dd1..bdf303f3 100644 --- a/confluent_client/doc/man/nodepower.ronn +++ b/confluent_client/doc/man/nodepower.ronn @@ -24,6 +24,9 @@ respond. * `reset`: Request immediate reset of nodes of the noderange. Nodes that are off will not react to this request. * `status`: Behave identically to having no argument passed at all. +* `pdu_status`: Query state of associated PDU outlets, if configured. +* `pdu_on`: Energize all PDU outlets associated with the noderange. +* `pdu_off`: De-energize all PDU outlets associated with the noderange. ## OPTIONS diff --git a/confluent_osdeploy/genesis/initramfs/opt/confluent/bin/rungenesis b/confluent_osdeploy/genesis/initramfs/opt/confluent/bin/rungenesis index 2e888928..b7035fe0 100644 --- a/confluent_osdeploy/genesis/initramfs/opt/confluent/bin/rungenesis +++ b/confluent_osdeploy/genesis/initramfs/opt/confluent/bin/rungenesis @@ -43,6 +43,20 @@ mkdir -p /etc/pki/tls/certs cat /tls/*.pem > /etc/pki/tls/certs/ca-bundle.crt TRIES=0 touch /etc/confluent/confluent.info +if [ -e /dev/disk/by-label/CNFLNT_IDNT ]; then + mkdir -p /media/ident + mount /dev/disk/by-label/CNFLNT_IDNT /media/ident + if [ -e /media/ident/genesis_bootstrap.sh ]; then + exec sh /media/ident/genesis_bootstrap.sh + fi +fi +if [ -e /dev/disk/by-label/GENESIS-X86 ]; then + mkdir -p /media/genesis + mount /dev/disk/by-label/GENESIS-X86 /media/genesis + if [ -e /media/genesis/genesis_bootstrap.sh ]; then + exec sh /media/genesis/genesis_bootstrap.sh + fi +fi cd /sys/class/net echo -n "Scanning for network configuration..." while ! grep ^EXTMGRINFO: /etc/confluent/confluent.info | awk -F'|' '{print $3}' | grep 1 >& /dev/null && [ "$TRIES" -lt 30 ]; do @@ -141,6 +155,23 @@ elif [ "$autoconfigmethod" = "static" ]; then ip route add default via $v4gw fi fi +nameserversec=0 +while read -r entry; do + if [ $nameserversec = 1 ]; then + if [[ $entry == "-"* ]] && [[ $entry != "- ''" ]]; then + echo nameserver ${entry#- } >> /etc/resolv.conf + continue + fi + fi + nameserversec=0 + if [ "${entry%:*}" = "nameservers" ]; then + nameserversec=1 + continue + fi +done < /etc/confluent/confluent.deploycfg +dnsdomain=$(grep ^dnsdomain: /etc/confluent/confluent.deploycfg) +dnsdomain=${dnsdomain#dnsdomain: } +echo search $dnsdomain >> /etc/resolv.conf echo -n "Initializing ssh..." ssh-keygen -A for pubkey in /etc/ssh/ssh_host*key.pub; do diff --git a/confluent_server/bin/osdeploy b/confluent_server/bin/osdeploy index d3f68647..378d00d2 100644 --- a/confluent_server/bin/osdeploy +++ b/confluent_server/bin/osdeploy @@ -22,6 +22,8 @@ import confluent.util as util import confluent.client as client import confluent.sshutil as sshutil import confluent.certutil as certutil +import confluent.netutil as netutil +import socket try: input = raw_input except NameError: @@ -149,10 +151,13 @@ def local_node_trust_setup(): neededlines = set([ 'HostbasedAuthentication yes', 'HostbasedUsesNameFromPacketOnly yes', 'IgnoreRhosts no']) - if domain and not myname.endswith(domain): - myprincipals.add('{0}.{1}'.format(myname, domain)) - if domain and '.' in myname and myname.endswith(domain): - myprincipals.add(myname.split('.')[0]) + myshortname = myname.split('.')[0] + myprincipals.add(myshortname) + if domain: + myprincipals.add('{0}.{1}'.format(myshortname, domain)) + for addr in netutil.get_my_addresses(): + addr = socket.inet_ntop(addr[0], addr[1]) + myprincipals.add(addr) for pubkey in glob.glob('/etc/ssh/ssh_host_*_key.pub'): currpubkey = open(pubkey, 'rb').read() cert = sshutil.sign_host_key(currpubkey, myname, myprincipals) diff --git a/confluent_server/confluent/auth.py b/confluent_server/confluent/auth.py index 9e675fbb..ce8cfd49 100644 --- a/confluent_server/confluent/auth.py +++ b/confluent_server/confluent/auth.py @@ -162,6 +162,10 @@ def authorize(name, element, tenant=False, operation='create', return False manager = configmanager.ConfigManager(tenant, username=user) userobj = manager.get_user(user) + if element and (element.startswith('/sessions/current/webauthn/registered_credentials/') or element.startswith('/sessions/current/webauthn/validate/')): + return userobj, manager, user, tenant, skipuserobj + if userobj and userobj.get('role', None) == 'Stub': + userobj = None if not userobj: for group in userutil.grouplist(user): userobj = manager.get_usergroup(group) diff --git a/confluent_server/confluent/config/attributes.py b/confluent_server/confluent/config/attributes.py index 647458b1..5444e0d7 100644 --- a/confluent_server/confluent/config/attributes.py +++ b/confluent_server/confluent/config/attributes.py @@ -534,6 +534,12 @@ node = { 'To support this scenario, the switch should be set up to allow independent operation of member ports123654 (e.g. lacp bypass mode or fallback mode).', 'validvalues': ('lacp', 'loadbalance', 'roundrobin', 'activebackup', 'none') }, + 'power.pdu': { + 'description': 'Specifies the managed PDU associated with a power input on the node' + }, + 'power.outlet': { + 'description': 'Species the outlet identifier on the PDU associoted with a power input on the node' + }, # 'id.modelnumber': { # 'description': 'The manufacturer dictated model number for the node', # }, diff --git a/confluent_server/confluent/config/configmanager.py b/confluent_server/confluent/config/configmanager.py index d42c6f96..2ccce28b 100644 --- a/confluent_server/confluent/config/configmanager.py +++ b/confluent_server/confluent/config/configmanager.py @@ -113,7 +113,7 @@ _attraliases = { 'bmcpass': 'secret.hardwaremanagementpassword', 'switchpass': 'secret.hardwaremanagementpassword', } -_validroles = ('Administrator', 'Operator', 'Monitor') +_validroles = ('Administrator', 'Operator', 'Monitor', 'Stub') membership_callback = None @@ -485,7 +485,7 @@ def attribute_is_invalid(attrname, attrval): def _get_valid_attrname(attrname): - if attrname.startswith('net.'): + if attrname.startswith('net.') or attrname.startswith('power.'): # For net.* attribtues, split on the dots and put back together # longer term we might want a generic approach, but # right now it's just net. attributes @@ -2447,10 +2447,10 @@ class ConfigManager(object): uid = tmpconfig[confarea].get('id', None) displayname = tmpconfig[confarea].get('displayname', None) self.create_user(user, uid=uid, displayname=displayname) - if 'cryptpass' in tmpconfig[confarea][user]: - self._cfgstore['users'][user]['cryptpass'] = \ - tmpconfig[confarea][user]['cryptpass'] - _mark_dirtykey('users', user, self.tenant) + for attrname in ('authid', 'authenticators', 'cryptpass'): + if attrname in tmpconfig[confarea][user]: + self._cfgstore['users'][user][attrname] = tmpconfig[confarea][user][attrname] + _mark_dirtykey('users', user, self.tenant) if sync: self._bg_sync_to_file() @@ -2548,8 +2548,13 @@ class ConfigManager(object): if statelessmode: return with cls._syncstate: - if (cls._syncrunning and cls._cfgwriter is not None and - cls._cfgwriter.isAlive()): + isalive = False + if cls._cfgwriter is not None: + try: + isalive = cls._cfgwriter.isAlive() + except AttributeError: + isalive = cls._cfgwriter.is_alive() + if (cls._syncrunning and isalive): cls._writepending = True return if cls._syncrunning: # This suggests an unclean write attempt, @@ -2777,8 +2782,8 @@ def dump_db_to_directory(location, password, redact=None, skipkeys=False): cfgfile.write('\n') bkupglobals = get_globals() if bkupglobals: - json.dump(bkupglobals, open(os.path.join(location, 'globals.json'), - 'w')) + with open(os.path.join(location, 'globals.json'), 'w') as globout: + json.dump(bkupglobals, globout) try: for tenant in os.listdir( os.path.join(ConfigManager._cfgdir, '/tenants/')): diff --git a/confluent_server/confluent/core.py b/confluent_server/confluent/core.py index 1a1631ca..fe6c4be4 100644 --- a/confluent_server/confluent/core.py +++ b/confluent_server/confluent/core.py @@ -457,6 +457,8 @@ def _init_core(): 'pluginattrs': ['hardwaremanagement.method'], 'default': 'ipmi', }), + 'inlets': PluginCollection({'handler': 'pdu'}), + 'outlets': PluginCollection({'pluginattrs': ['hardwaremanagement.method']}), 'reseat': PluginRoute({'handler': 'enclosure'}), }, 'sensors': { diff --git a/confluent_server/confluent/discovery/protocols/pxe.py b/confluent_server/confluent/discovery/protocols/pxe.py index 906cc425..97efdad9 100644 --- a/confluent_server/confluent/discovery/protocols/pxe.py +++ b/confluent_server/confluent/discovery/protocols/pxe.py @@ -393,7 +393,7 @@ def snoop(handler, protocol=None, nodeguess=None): if level == socket.IPPROTO_IP and typ == IP_PKTINFO: idx, recv = struct.unpack('II', cmsgarr[16:24]) recv = ipfromint(recv) - rqv = memoryview(rawbuffer) + rqv = memoryview(rawbuffer)[:i] if rawbuffer[0] == 1: # Boot request process_dhcp4req(handler, nodeguess, cfg, net4, idx, recv, rqv) elif netc == net6: diff --git a/confluent_server/confluent/discovery/protocols/ssdp.py b/confluent_server/confluent/discovery/protocols/ssdp.py index d483e7fc..762b643a 100644 --- a/confluent_server/confluent/discovery/protocols/ssdp.py +++ b/confluent_server/confluent/discovery/protocols/ssdp.py @@ -281,6 +281,8 @@ def snoop(handler, byehandler=None, protocol=None, uuidlookup=None): def _get_svrip(peerdata): for addr in peerdata['addresses']: if addr[0].startswith('fe80::'): + if '%' not in addr[0]: + return addr[0] + '%{0}'.format(addr[3]) return addr[0] return peerdata['addresses'][0][0] diff --git a/confluent_server/confluent/httpapi.py b/confluent_server/confluent/httpapi.py index 295f99f2..ec80a095 100644 --- a/confluent_server/confluent/httpapi.py +++ b/confluent_server/confluent/httpapi.py @@ -21,6 +21,10 @@ try: import Cookie except ModuleNotFoundError: import http.cookies as Cookie +try: + import confluent.webauthn as webauthn +except ImportError: + webauthn = None import confluent.auth as auth import confluent.config.attributes as attribs import confluent.consoleserver as consoleserver @@ -207,6 +211,8 @@ def _should_skip_authlog(env): if '/sessions/current/async' in env['PATH_INFO']: # this is effectively invisible return True + if '/sessions/current/webauthn/registered_credentials' in env['PATH_INFO']: + return True if (env['REQUEST_METHOD'] == 'GET' and ('/sensors/' in env['PATH_INFO'] or '/health/' in env['PATH_INFO'] or @@ -263,18 +269,24 @@ def _csrf_valid(env, session): env['HTTP_CONFLUENTAUTHTOKEN'] == session['csrftoken']) -def _authorize_request(env, operation): +def _authorize_request(env, operation, reqbody): """Grant/Deny access based on data from wsgi env """ authdata = None name = '' sessionid = None + sessid = None cookie = Cookie.SimpleCookie() element = env['PATH_INFO'] if element.startswith('/sessions/current/'): - element = None - if 'HTTP_COOKIE' in env: + if (element.startswith('/sessions/current/webauthn/registered_credentials/') + or element.startswith('/sessions/current/webauthn/validate/')): + name = element.rsplit('/')[-1] + authdata = auth.authorize(name, element=element, operation=operation) + else: + element = None + if (not authdata) and 'HTTP_COOKIE' in env: cidx = (env['HTTP_COOKIE']).find('confluentsessionid=') if cidx >= 0: sessionid = env['HTTP_COOKIE'][cidx+19:cidx+51] @@ -322,18 +334,13 @@ def _authorize_request(env, operation): return {'code': 403} elif not authdata: return {'code': 401} - sessid = util.randomstring(32) - while sessid in httpsessions: - sessid = util.randomstring(32) - httpsessions[sessid] = {'name': name, 'expiry': time.time() + 90, - 'skipuserobject': authdata[4], - 'inflight': set([])} - if 'HTTP_CONFLUENTAUTHTOKEN' in env: - httpsessions[sessid]['csrftoken'] = util.randomstring(32) - cookie['confluentsessionid'] = util.stringify(sessid) - cookie['confluentsessionid']['secure'] = 1 - cookie['confluentsessionid']['httponly'] = 1 - cookie['confluentsessionid']['path'] = '/' + sessid = _establish_http_session(env, authdata, name, cookie) + if authdata and element and element.startswith('/sessions/current/webauthn/validate/'): + if webauthn: + for rsp in webauthn.handle_api_request(element, env, None, authdata[2], authdata[1], None, reqbody, None): + if rsp['verified']: + sessid = _establish_http_session(env, authdata, name, cookie) + break skiplog = _should_skip_authlog(env) if authdata: auditmsg = { @@ -352,17 +359,32 @@ def _authorize_request(env, operation): auditmsg['user'] = util.stringify(authdata[2]) if sessid is not None: authinfo['sessionid'] = sessid + if 'csrftoken' in httpsessions[sessid]: + authinfo['authtoken'] = httpsessions[sessid]['csrftoken'] + httpsessions[sessid]['cfgmgr'] = authdata[1] if not skiplog: auditlog.log(auditmsg) - if 'csrftoken' in httpsessions[sessid]: - authinfo['authtoken'] = httpsessions[sessid]['csrftoken'] - httpsessions[sessid]['cfgmgr'] = authdata[1] return authinfo elif authdata is None: return {'code': 401} else: return {'code': 403} +def _establish_http_session(env, authdata, name, cookie): + sessid = util.randomstring(32) + while sessid in httpsessions: + sessid = util.randomstring(32) + httpsessions[sessid] = {'name': name, 'expiry': time.time() + 90, + 'skipuserobject': authdata[4], + 'inflight': set([])} + if 'HTTP_CONFLUENTAUTHTOKEN' in env: + httpsessions[sessid]['csrftoken'] = util.randomstring(32) + cookie['confluentsessionid'] = util.stringify(sessid) + cookie['confluentsessionid']['secure'] = 1 + cookie['confluentsessionid']['httponly'] = 1 + cookie['confluentsessionid']['path'] = '/' + return sessid + def _pick_mimetype(env): """Detect the http indicated mime to send back. @@ -603,7 +625,7 @@ def resourcehandler_backend(env, start_response): if operation != 'retrieve' and 'restexplorerop' in querydict: operation = querydict['restexplorerop'] del querydict['restexplorerop'] - authorized = _authorize_request(env, operation) + authorized = _authorize_request(env, operation, reqbody) if 'logout' in authorized: start_response('200 Successful logout', headers) yield('{"result": "200 - Successful logout"}') @@ -632,7 +654,7 @@ def resourcehandler_backend(env, start_response): raise Exception("Unrecognized code from auth engine") headers.extend( ("Set-Cookie", m.OutputString()) - for m in authorized['cookie'].values()) + for m in authorized.get('cookie', {}).values()) cfgmgr = authorized['cfgmgr'] if (operation == 'create') and env['PATH_INFO'] == '/sessions/current/async': pagecontent = "" @@ -830,6 +852,14 @@ def resourcehandler_backend(env, start_response): tlvdata.unicode_dictvalues(sessinfo) yield json.dumps(sessinfo) return + elif url.startswith('/sessions/current/webauthn/'): + if not webauthn: + start_response('501 Not Implemented', headers) + yield '' + return + for rsp in webauthn.handle_api_request(url, env, start_response, authorized['username'], cfgmgr, headers, reqbody, authorized): + yield rsp + return resource = '.' + url[url.rindex('/'):] lquerydict = copy.deepcopy(querydict) try: diff --git a/confluent_server/confluent/messages.py b/confluent_server/confluent/messages.py index 396cd58f..6f0c3c25 100644 --- a/confluent_server/confluent/messages.py +++ b/confluent_server/confluent/messages.py @@ -515,6 +515,8 @@ def get_input_message(path, operation, inputdata, nodes=None, multinode=False, return InputVolumes(path, nodes, inputdata) elif 'inventory/firmware/updates/active' in '/'.join(path) and inputdata: return InputFirmwareUpdate(path, nodes, inputdata, configmanager) + elif ('/'.join(path).startswith('power/inlets/') or '/'.join(path).startswith('power/outlets/')) and inputdata: + return InputPowerMessage(path, nodes, inputdata) elif '/'.join(path).startswith('media/detach'): return DetachMedia(path, nodes, inputdata) elif '/'.join(path).startswith('media/') and inputdata: diff --git a/confluent_server/confluent/plugins/hardwaremanagement/geist.py b/confluent_server/confluent/plugins/hardwaremanagement/geist.py new file mode 100644 index 00000000..8f977eb5 --- /dev/null +++ b/confluent_server/confluent/plugins/hardwaremanagement/geist.py @@ -0,0 +1,124 @@ +# Copyright 2022 Lenovo +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pyghmi.util.webclient as wc +import confluent.util as util +import confluent.messages as msg +import confluent.exceptions as exc + + +class GeistClient(object): + def __init__(self, pdu, configmanager): + self.node = pdu + self.configmanager = configmanager + self._token = None + self._wc = None + self.username = None + + @property + def token(self): + if not self._token: + self._token = self.login(self.configmanager) + return self._token + + @property + def wc(self): + if self._wc: + return self._wc + targcfg = self.configmanager.get_node_attributes(self.node, + ['hardwaremanagement.manager'], + decrypt=True) + targcfg = targcfg.get(self.node, {}) + target = targcfg.get( + 'hardwaremanagement.manager', {}).get('value', None) + if not target: + target = self.node + cv = util.TLSCertVerifier( + self.configmanager, self.node, + 'pubkeys.tls_hardwaremanager').verify_cert + self._wc = wc.SecureHTTPConnection(target, verifycallback=cv) + return self._wc + + def login(self, configmanager): + credcfg = configmanager.get_node_attributes(self.node, + ['secret.hardwaremanagementuser', + 'secret.hardwaremanagementpassword'], + decrypt=True) + credcfg = credcfg.get(self.node, {}) + username = credcfg.get( + 'secret.hardwaremanagementuser', {}).get('value', None) + passwd = credcfg.get( + 'secret.hardwaremanagementpassword', {}).get('value', None) + if not isinstance(username, str): + username = username.decode('utf8') + if not isinstance(passwd, str): + passwd = passwd.decode('utf8') + if not username or not passwd: + raise Exception('Missing username or password') + self.username = username + rsp = self.wc.grab_json_response( + '/api/auth/{0}'.format(username), + {'cmd': 'login', 'data': {'password': passwd}}) + token = rsp['data']['token'] + return token + + def logout(self): + if self._token: + self.wc.grab_json_response('/api/auth/{0}'.format(self.username), + {'cmd': 'logout', 'token': self.token}) + self._token = None + + def get_outlet(self, outlet): + rsp = self.wc.grab_json_response('/api/dev') + rsp = rsp['data'] + if len(rsp) != 1: + raise Exception('Multiple PDUs not supported per pdu') + pduname = list(rsp)[0] + outlet = rsp[pduname]['outlet'][str(int(outlet) - 1)] + state = outlet['state'].split('2')[-1] + return state + + def set_outlet(self, outlet, state): + rsp = self.wc.grab_json_response('/api/dev') + if len(rsp['data']) != 1: + self.logout() + raise Exception('Multiple PDUs per endpoint not supported') + pdu = list(rsp['data'])[0] + outlet = int(outlet) - 1 + rsp = self.wc.grab_json_response( + '/api/dev/{0}/outlet/{1}'.format(pdu, outlet), + {'cmd': 'control', 'token': self.token, + 'data': {'action': state, 'delay': False}}) + + +def retrieve(nodes, element, configmanager, inputdata): + if 'outlets' not in element: + for node in nodes: + yield msg.ConfluentResourceUnavailable(node, 'Not implemented') + return + for node in nodes: + gc = GeistClient(node, configmanager) + state = gc.get_outlet(element[-1]) + yield msg.PowerState(node=node, state=state) + +def update(nodes, element, configmanager, inputdata): + if 'outlets' not in element: + yield msg.ConfluentResourceUnavailable(node, 'Not implemented') + return + for node in nodes: + gc = GeistClient(node, configmanager) + newstate = inputdata.powerstate(node) + gc.set_outlet(element[-1], newstate) + for res in retrieve(nodes, element, configmanager, inputdata): + yield res diff --git a/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py b/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py index b2e5615c..4fbd9e80 100644 --- a/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py +++ b/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py @@ -482,7 +482,7 @@ class IpmiHandler(object): self.tenant = cfg.tenant tenant = cfg.tenant while ((node, tenant) not in persistent_ipmicmds or - not persistent_ipmicmds[(node, tenant)].ipmi_session.logged or + not (persistent_ipmicmds[(node, tenant)].ipmi_session.logged or persistent_ipmicmds[(node, tenant)].ipmi_session.logging) or persistent_ipmicmds[(node, tenant)].ipmi_session.broken): try: persistent_ipmicmds[(node, tenant)].close_confluent() @@ -514,6 +514,11 @@ class IpmiHandler(object): raise exc.TargetEndpointUnreachable(ge.strerror) raise self.ipmicmd = persistent_ipmicmds[(node, tenant)] + giveup = util.monotonic_time() + 60 + while not self.ipmicmd.ipmi_session.broken and not self.ipmicmd.ipmi_session.logged and self.ipmicmd.ipmi_session.logging: + self.ipmicmd.ipmi_session.wait_for_rsp(3) + if util.monotonic_time() > giveup: + self.ipmicmd.ipmi_session.broken = True bootdevices = { 'optical': 'cd' diff --git a/confluent_server/confluent/plugins/hardwaremanagement/pdu.py b/confluent_server/confluent/plugins/hardwaremanagement/pdu.py new file mode 100644 index 00000000..7eaa3330 --- /dev/null +++ b/confluent_server/confluent/plugins/hardwaremanagement/pdu.py @@ -0,0 +1,79 @@ +# Copyright 2017 Lenovo +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import confluent.core as core +import confluent.messages as msg +import pyghmi.exceptions as pygexc +import confluent.exceptions as exc + +def retrieve(nodes, element, configmanager, inputdata): + emebs = configmanager.get_node_attributes( + nodes, (u'power.*pdu', u'power.*outlet')) + if element == ['power', 'inlets']: + outletnames = set([]) + for node in nodes: + for attrib in emebs[node]: + attrib = attrib.replace('power.', '').rsplit('.', 1) + if len(attrib) > 1: + outletnames.add('inlet_' + attrib[0]) + else: + outletnames.add('default') + if outletnames: + outletnames.add('all') + for inlet in outletnames: + yield msg.ChildCollection(inlet) + elif len(element) == 3: + inletname = element[-1] + outlets = get_outlets(nodes, emebs, inletname) + for node in outlets: + for pgroup in outlets[node]: + pdu = outlets[node][pgroup]['pdu'] + outlet = outlets[node][pgroup]['outlet'] + for rsp in core.handle_path( + '/nodes/{0}/power/outlets/{1}'.format(pdu, outlet), + 'retrieve', configmanager): + yield msg.KeyValueData({pgroup: rsp.kvpairs['state']['value']}, node) + +def get_outlets(nodes, emebs, inletname): + outlets = {} + for node in nodes: + if node not in outlets: + outlets[node] = {} + for attrib in emebs[node]: + v = emebs[node][attrib].get('value', None) + if not v: + continue + attrib = attrib.replace('power.', '').rsplit('.', 1) + if len(attrib) > 1: + pgroup = 'inlet_' + attrib[0] + else: + pgroup = 'default' + if inletname == 'all' or pgroup == inletname: + if pgroup not in outlets[node]: + outlets[node][pgroup] = {} + outlets[node][pgroup][attrib[-1]] = v + return outlets + + +def update(nodes, element, configmanager, inputdata): + emebs = configmanager.get_node_attributes( + nodes, (u'power.*pdu', u'power.*outlet')) + inletname = element[-1] + outlets = get_outlets(nodes, emebs, inletname) + for node in outlets: + for pgroup in outlets[node]: + pdu = outlets[node][pgroup]['pdu'] + outlet = outlets[node][pgroup]['outlet'] + for rsp in core.handle_path('/nodes/{0}/power/outlets/{1}'.format(pdu, outlet), + 'update', configmanager, inputdata={'state': inputdata.powerstate(node)}): + yield msg.KeyValueData({pgroup: rsp.kvpairs['state']['value']}, node) diff --git a/confluent_server/confluent/webauthn.py b/confluent_server/confluent/webauthn.py new file mode 100644 index 00000000..7e3b148f --- /dev/null +++ b/confluent_server/confluent/webauthn.py @@ -0,0 +1,128 @@ +import base64 +import confluent.tlvdata as tlvdata +import confluent.util as util +import json +import pywarp +import pywarp.backends +import pywarp.credentials + +challenges = {} + +class ConfluentBackend(pywarp.backends.CredentialStorageBackend): + def __init__(self, cfg): + self.cfg = cfg + + def get_credential_ids_by_email(self, email): + if not isinstance(email, str): + email = email.decode('utf8') + authenticators = self.cfg.get_user(email).get('authenticators', {}) + if not authenticators: + raise Exception('No authenticators found') + for cid in authenticators: + yield base64.b64decode(cid) + + def get_credential_by_email_id(self, email, id): + if not isinstance(email, str): + email = email.decode('utf8') + authenticators = self.cfg.get_user(email).get('authenticators', {}) + cid = base64.b64encode(id).decode('utf8') + pk = authenticators[cid]['cpk'] + pk = base64.b64decode(pk) + return pywarp.credentials.Credential(credential_id=id, credential_public_key=pk) + + def get_credential_by_email(self, email): + if not isinstance(email, str): + email = email.decode('utf8') + authenticators = self.cfg.get_user(email) + cid = list(authenticators)[0] + cred = authenticators[cid] + cid = base64.b64decode(cred['cid']) + cpk = base64.b64decode(cred['cpk']) + return pywarp.credentials.Credential(credential_id=cid, credential_public_key=cpk) + + def save_credential_for_user(self, email, credential): + if not isinstance(email, str): + email = email.decode('utf8') + cid = base64.b64encode(credential.id).decode('utf8') + credential = {'cid': cid, 'cpk': base64.b64encode(bytes(credential.public_key)).decode('utf8')} + authenticators = self.cfg.get_user(email).get('authenticators', {}) + authenticators[cid] = credential + self.cfg.set_user(email, {'authenticators': authenticators}) + + def save_challenge_for_user(self, email, challenge, type): + if not isinstance(email, str): + email = email.decode('utf8') + challenges[email] = challenge + + def get_challenge_for_user(self, email, type): + if not isinstance(email, str): + email = email.decode('utf8') + return challenges[email] + + +def handle_api_request(url, env, start_response, username, cfm, headers, reqbody, authorized): + if env['REQUEST_METHOD'] != 'POST': + raise Exception('Only POST supported for webauthn operations') + url = url.replace('/sessions/current/webauthn', '') + if url == '/registration_options': + rp = pywarp.RelyingPartyManager('Confluent Web UI', credential_storage_backend=ConfluentBackend(cfm), require_attestation=False) + userinfo = cfm.get_user(username) + if not userinfo: + cfm.create_user(username, role='Stub') + userinfo = cfm.get_user(username) + authid = userinfo.get('authid', None) + if not authid: + authid = util.randomstring(64) + cfm.set_user(username, {'authid': authid}) + opts = rp.get_registration_options(username) + # pywarp generates an id derived + # from username, which is a 'must not' in the spec + # we replace that with a complying approach + opts['user']['id'] = authid + if 'icon' in opts['user']: + del opts['user']['icon'] + if 'id' in opts['rp']: + del opts['rp']['id'] + start_response('200 OK', headers) + yield json.dumps(opts) + elif url.startswith('/registered_credentials/'): + username = url.rsplit('/', 1)[-1] + rp = pywarp.RelyingPartyManager('Confluent Web UI', credential_storage_backend=ConfluentBackend(cfm)) + if not isinstance(username, bytes): + username = username.encode('utf8') + opts = rp.get_authentication_options(username) + opts['challenge'] = base64.b64encode(opts['challenge']).decode('utf8') + start_response('200 OK', headers) + yield json.dumps(opts) + elif url.startswith('/validate/'): + username = url.rsplit('/', 1)[-1] + if not isinstance(username, bytes): + username = username.encode('utf8') + rp = pywarp.RelyingPartyManager('Confluent Web UI', credential_storage_backend=ConfluentBackend(cfm)) + req = json.loads(reqbody) + for x in req: + req[x] = base64.b64decode(req[x].replace('-', '+').replace('_', '/')) + req['email'] = username + rsp = rp.verify(**req) + if start_response: + start_response('200 OK', headers) + sessinfo = {'username': username} + if 'authtoken' in authorized: + sessinfo['authtoken'] = authorized['authtoken'] + if 'sessionid' in authorized: + sessinfo['sessionid'] = authorized['sessionid'] + tlvdata.unicode_dictvalues(sessinfo) + yield json.dumps(sessinfo) + else: + yield rsp + elif url == '/register_credential': + rp = pywarp.RelyingPartyManager('Confluent Web UI', credential_storage_backend=ConfluentBackend(cfm), require_attestation=False) + req = json.loads(reqbody) + for x in req: + req[x] = base64.b64decode(req[x].replace('-', '+').replace('_', '/')) + if not isinstance(username, bytes): + username = username.encode('utf8') + req['email'] = username + rsp = rp.register(**req) + start_response('200 OK', headers) + yield json.dumps(rsp) \ No newline at end of file diff --git a/confluent_server/confluent_server.spec.tmpl b/confluent_server/confluent_server.spec.tmpl index eef95ae0..63abd54d 100644 --- a/confluent_server/confluent_server.spec.tmpl +++ b/confluent_server/confluent_server.spec.tmpl @@ -17,7 +17,7 @@ Requires: confluent_vtbufferd Requires: python3-pyghmi >= 1.0.34, python3-eventlet, python3-greenlet, python3-pycryptodomex >= 3.4.7, confluent_client == %{version}, python3-pyparsing, python3-paramiko, python3-dns, python3-netifaces, python3-pyasn1 >= 0.2.3, python3-pysnmp >= 4.3.4, python3-lxml, python3-eficompressor, python3-setuptools, python3-dateutil, python3-enum34, python3-asn1crypto, python3-cffi, python3-pyOpenSSL, python3-monotonic, python3-websocket-client python3-msgpack python3-libarchive-c python3-yaml openssl iproute %else %if "%{dist}" == ".el9" -Requires: python3-pyghmi >= 1.0.34, python3-eventlet, python3-greenlet, python3-pycryptodomex >= 3.4.7, confluent_client == %{version}, python3-pyparsing, python3-paramiko, python3-dns, python3-netifaces, python3-pyasn1 >= 0.2.3, python3-pysnmp >= 4.3.4, python3-lxml, python3-eficompressor, python3-setuptools, python3-dateutil, python3-enum34, python3-asn1crypto, python3-cffi, python3-pyOpenSSL, python3-monotonic, python3-websocket-client python3-msgpack python3-libarchive-c python3-yaml openssl iproute +Requires: python3-pyghmi >= 1.0.34, python3-eventlet, python3-greenlet, python3-pycryptodomex >= 3.4.7, confluent_client == %{version}, python3-pyparsing, python3-paramiko, python3-dns, python3-netifaces, python3-pyasn1 >= 0.2.3, python3-pysnmp >= 4.3.4, python3-lxml, python3-eficompressor, python3-setuptools, python3-dateutil, python3-cffi, python3-pyOpenSSL, python3-websocket-client python3-msgpack python3-libarchive-c python3-yaml openssl iproute %else Requires: python-pyghmi >= 1.0.34, python-eventlet, python-greenlet, python-pycryptodomex >= 3.4.7, confluent_client == %{version}, python-pyparsing, python-paramiko, python-dnspython, python-netifaces, python2-pyasn1 >= 0.2.3, python-pysnmp >= 4.3.4, python-lxml, python-eficompressor, python-setuptools, python-dateutil, python2-websocket-client python2-msgpack python-libarchive-c python-yaml python-monotonic %endif diff --git a/genesis/97genesis/install-base b/genesis/97genesis/install-base index da97dc9c..b0e56743 100644 --- a/genesis/97genesis/install-base +++ b/genesis/97genesis/install-base @@ -10,7 +10,7 @@ dracut_install awk egrep dirname bc expr sort dracut_install ssh sshd vi reboot lspci parted tmux mkfs mkfs.ext4 mkfs.xfs xfs_db mkswap dracut_install efibootmgr dracut_install du df ssh-keygen scp clear dhclient lldpd lldpcli tee -dracut_install /lib64/libnss_dns-2.28.so /lib64/libnss_dns.so.2 +dracut_install /lib64/libnss_dns-2.28.so /lib64/libnss_dns.so.2 /lib64/libnss_myhostname.so.2 dracut_install ldd uptime /usr/lib64/libnl-3.so.200 dracut_install poweroff date /etc/nsswitch.conf /etc/services /etc/protocols dracut_install /usr/share/terminfo/x/xterm /usr/share/terminfo/l/linux /usr/share/terminfo/v/vt100 /usr/share/terminfo/x/xterm-color /usr/share/terminfo/s/screen /usr/share/terminfo/x/xterm-256color /usr/share/terminfo/p/putty-256color /usr/share/terminfo/p/putty /usr/share/terminfo/d/dumb