major rewrite of the auth function
This commit is contained in:
parent
20052a773d
commit
1f405f7990
4 changed files with 89 additions and 56 deletions
84
clientsdb.py
84
clientsdb.py
|
@ -16,23 +16,28 @@ def addclient(vmid, vmname, clientid, clientname, clientemail, vmpass):
|
||||||
clientsdb = readclientsdb()
|
clientsdb = readclientsdb()
|
||||||
|
|
||||||
if str(clientid) in clientsdb:
|
if str(clientid) in clientsdb:
|
||||||
ioconfig.logger.info('clients> client ' + clientid + ' already exists. merging.')
|
ioconfig.logger.info('client[{}]> already exist. merging.'.format(clientid))
|
||||||
else:
|
else:
|
||||||
ioconfig.logger.info('clients> client ' + clientid + ' does not exist. creating.')
|
ioconfig.logger.info('client[{}]> does not exist. creating...'.format(clientid))
|
||||||
vcard = { 'name':str(clientname), 'email':str(clientemail) }
|
#generate password and send it to the client
|
||||||
|
newpass = utils.genpassword(30)
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
b_newpass = newpass.encode('utf-8')
|
||||||
|
encpasswd = bcrypt.hashpw(b_newpass, salt).decode('utf-8')
|
||||||
|
vcard = { 'name':str(clientname), 'email':str(clientemail), 'encpasswd':str(encpasswd) }
|
||||||
newclient = { str(clientid):vcard }
|
newclient = { str(clientid):vcard }
|
||||||
clientsdb.update(newclient)
|
clientsdb.update(newclient)
|
||||||
ioconfig.logger.info('clients> vmid {} owner set to {} (id: {}, email: {})'.format(vmid, clientname, clientid, clientemail))
|
#TODO: 1. Send initial email to the user as we wont use the internal auth from now on.
|
||||||
|
#TODO: 2. Sync with proxmaster-admin database (shell command could be used for this one)
|
||||||
|
ioconfig.logger.info('client[{}]> vmid {} is now owned by {} ({})'.format(clientemail, vmid, clientid, clientname))
|
||||||
|
|
||||||
#create initial vm template
|
#create initial vm template
|
||||||
vmdata = { 'hostname':str(vmname), 'vmid':str(vmid), 'ownerid':str(clientid) }
|
vmdata = { 'hostname':str(vmname), 'vmid':str(vmid), 'ownerid':str(clientid) }
|
||||||
clientsdb[str(clientid)][str(vmid)] = vmdata
|
clientsdb[str(clientid)][str(vmid)] = vmdata
|
||||||
writeclientsdb(clientsdb)
|
writeclientsdb(clientsdb)
|
||||||
#set password for the first time...
|
|
||||||
setencpasswd(vmname, vmpass)
|
|
||||||
|
|
||||||
|
|
||||||
def setencpasswd(vmname, newpass):
|
def setencpasswd(clientemail, newpass):
|
||||||
""" setup a new management password """
|
""" setup a new management password """
|
||||||
salt = bcrypt.gensalt()
|
salt = bcrypt.gensalt()
|
||||||
b_newpass = newpass.encode('utf-8')
|
b_newpass = newpass.encode('utf-8')
|
||||||
|
@ -41,62 +46,69 @@ def setencpasswd(vmname, newpass):
|
||||||
try:
|
try:
|
||||||
clientsdb = readclientsdb()
|
clientsdb = readclientsdb()
|
||||||
#print(clientsdb)
|
#print(clientsdb)
|
||||||
path = utils.get_path(clientsdb, vmname)
|
path = utils.get_path(clientsdb, clientemail)
|
||||||
#print(path)
|
#print(path)
|
||||||
c_id = str(path[0])
|
c_id = str(path[0])
|
||||||
v_id = str(path[1])
|
|
||||||
#check the returned path with forward query
|
#check the returned path with forward query
|
||||||
query = clientsdb[c_id][v_id]['hostname']
|
query = clientsdb[c_id]['email']
|
||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if query != vmname:
|
if query != clientemail:
|
||||||
ioconfig.logger.critical('clients> test query returns different vmname! check clients.json consistency!')
|
ioconfig.logger.critical('clients.db> test query returns different vmname! check clients db for consistency!')
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
clientsdb[c_id][v_id]['encpasswd'] = encpasswd
|
clientsdb[c_id]['encpasswd'] = encpasswd
|
||||||
ioconfig.logger.info('clients> {} (clientid: {}, vmid: {}) got its management password changed!'.format(query, c_id, v_id))
|
ioconfig.logger.info('client[{}]> got its management password changed!'.format(clientemail))
|
||||||
writeclientsdb(clientsdb)
|
writeclientsdb(clientsdb)
|
||||||
#TODO: change lxc container password
|
#TODO: Send new email to the client to notify the password change. This time sending the password in plain text is not needed.
|
||||||
|
|
||||||
|
|
||||||
def validate(clientemail, srvpass):
|
def validate(clientemail, password):
|
||||||
""" return vmid or false if credentials match something in clientdb. useful for authing extrnal admin panels """
|
""" return list of owned vmids or false if credentials match an user form the database.
|
||||||
|
useful for authing extrnal admin panels """
|
||||||
|
#1. search for the client
|
||||||
try:
|
try:
|
||||||
clientsdb = readclientsdb()
|
clientsdb = readclientsdb()
|
||||||
path = utils.get_path(clientsdb, clientemail)
|
path = utils.get_path(clientsdb, clientemail)
|
||||||
c_id = str(path[0])
|
c_id = str(path[0])
|
||||||
#check the returned path with forward query
|
#check the returned path with forward query
|
||||||
ioconfig.logger.info('clients> {} was found with clientid: {}'.format(clientemail, c_id))
|
ioconfig.logger.info('client[{}]> found. path={}'.format(clientemail, str(path)))
|
||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
ioconfig.logger.warning('clients> {} was not found in the database!'.format(clientemail))
|
ioconfig.logger.warning('clients.db> {} was not found in the database!'.format(clientemail))
|
||||||
#log bad ips here...
|
#log bad ips here...
|
||||||
return False
|
return False
|
||||||
|
|
||||||
vmlist = clientsdb[c_id]
|
#2. check the password
|
||||||
|
encpass = clientsdb[c_id]['encpasswd']
|
||||||
|
b_srvpass = password.encode('utf-8')
|
||||||
|
b_encpass = encpass.encode('utf-8')
|
||||||
|
|
||||||
|
if (hmac.compare_digest(bcrypt.hashpw(b_srvpass, b_encpass), b_encpass)):
|
||||||
|
#login successful
|
||||||
|
ioconfig.logger.info('client[{}]> logged in successfully'.format(clientemail))
|
||||||
|
#TODO: Notify admin
|
||||||
|
#3. generate vmlist to return the owned ids to the client.
|
||||||
|
return clientvms(clientsdb[c_id])
|
||||||
|
else:
|
||||||
|
ioconfig.logger.warning('clients> {} ACCESS DENIED!'.format(vmid))
|
||||||
|
#cant compare password
|
||||||
|
#TODO: Log attempts and block.
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def clientvms(vmlist):
|
||||||
|
""" generate vmlist """
|
||||||
#clear unused objects. perhaps there is a better way to do this but im kinda anxious today...
|
#clear unused objects. perhaps there is a better way to do this but im kinda anxious today...
|
||||||
vmlist.pop('name')
|
vmlist.pop('name')
|
||||||
vmlist.pop('email')
|
vmlist.pop('email')
|
||||||
|
|
||||||
#try each vmid owned by this user for a password match
|
response = []
|
||||||
for vmid,data in vmlist.items():
|
for vmid,data in vmlist.items():
|
||||||
print(vmid)
|
print(vmid)
|
||||||
|
|
||||||
print(data)
|
print(data)
|
||||||
#try to capture the encrypted password
|
|
||||||
encpass = data['encpasswd']
|
|
||||||
b_srvpass = srvpass.encode('utf-8')
|
|
||||||
b_encpass = encpass.encode('utf-8')
|
|
||||||
if (hmac.compare_digest(bcrypt.hashpw(b_srvpass, b_encpass), b_encpass)):
|
|
||||||
#login successful
|
|
||||||
ioconfig.logger.info('clients> {} was validated successfully by {}'.format(vmid, clientemail))
|
|
||||||
response = { 'vmid':vmid }
|
response = { 'vmid':vmid }
|
||||||
else:
|
|
||||||
ioconfig.logger.warning('clients> {} ACCESS DENIED!'.format(vmid))
|
|
||||||
#cant compare password
|
|
||||||
response = { }
|
|
||||||
#TODO: this will require major rewrite again.. or it will fail to auth 2 machines with same password. lame..
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ -136,5 +148,5 @@ def writeclientsdb(clientsdb):
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
#setencpasswd('srv.test1.com', 'todos')
|
setencpasswd('daniel@deflax.net', 'todos')
|
||||||
validate('daniel@deflax.net', 'todos')
|
print(validate('daniel@deflax.net', 'todos'))
|
||||||
|
|
6
grid.py
6
grid.py
|
@ -168,7 +168,7 @@ def query_region(region_name):
|
||||||
|
|
||||||
for region in all_regions:
|
for region in all_regions:
|
||||||
if grid_data[region]['region'] == region_name:
|
if grid_data[region]['region'] == region_name:
|
||||||
logger.info('grid> region ' + region_name + ' found')
|
logger.info('region[{}]> found: id={}'.format(region_name, region))
|
||||||
return grid_data[region]['id']
|
return grid_data[region]['id']
|
||||||
break
|
break
|
||||||
logger.error('grid> cant find region ' + region_name)
|
logger.error('grid> cant find region ' + region_name)
|
||||||
|
@ -339,10 +339,10 @@ def query_vm(req_vmid):
|
||||||
try:
|
try:
|
||||||
vm_type = grid_data[str(region_id)][str(slave_id)][str(target)]['type']
|
vm_type = grid_data[str(region_id)][str(slave_id)][str(target)]['type']
|
||||||
except:
|
except:
|
||||||
logger.error('{}> type is unknown!'.format(vm_id))
|
logger.error('vm[{}]> type is unknown!'.format(vm_id))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info('{}> region={}, slave={}, type={} found.'.format(target, region_id, slave_id, vm_type))
|
logger.info('vm[{}]> type {} found. path=region={} found.'.format(target, vm_type, str(path)))
|
||||||
|
|
||||||
return slave_id, vm_type
|
return slave_id, vm_type
|
||||||
|
|
||||||
|
|
28
plugin.py
28
plugin.py
|
@ -148,13 +148,13 @@ def vmstart(vm_id):
|
||||||
proxobject = auth(slave_id)
|
proxobject = auth(slave_id)
|
||||||
vm_type = vm_type.lower()
|
vm_type = vm_type.lower()
|
||||||
slave_name = proxobject.cluster.status.get()[0]['name']
|
slave_name = proxobject.cluster.status.get()[0]['name']
|
||||||
ioconfig.logger.info('grid[%s]> starting %s %s' % (slave_name, vm_type, vm_id))
|
ioconfig.logger.info('slave[%s]> starting %s %s' % (slave_name, vm_type, vm_id))
|
||||||
|
|
||||||
if vm_type == 'kvm':
|
if vm_type == 'kvm':
|
||||||
result = proxobject.nodes(slave_name).qemu(vm_id).status.start.post()
|
result = proxobject.nodes(slave_name).qemu(vm_id).status.start.post()
|
||||||
if vm_type == 'lxc':
|
if vm_type == 'lxc':
|
||||||
result = proxobject.nodes(slave_name).lxc(vm_id).status.start.post()
|
result = proxobject.nodes(slave_name).lxc(vm_id).status.start.post()
|
||||||
#ioconfig.logger.info('grid[{}]> {}'.format(slave_name, result))
|
#ioconfig.logger.info('slave[{}]> {}'.format(slave_name, result))
|
||||||
response = { 'status':'START' }
|
response = { 'status':'START' }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -165,13 +165,13 @@ def vmshutdown(vm_id):
|
||||||
proxobject = auth(slave_id)
|
proxobject = auth(slave_id)
|
||||||
vm_type = vm_type.lower()
|
vm_type = vm_type.lower()
|
||||||
slave_name = proxobject.cluster.status.get()[0]['name']
|
slave_name = proxobject.cluster.status.get()[0]['name']
|
||||||
ioconfig.logger.info('grid[%s]> acpi shutdown %s %s' % (slave_name, vm_type, vm_id))
|
ioconfig.logger.info('slave[%s]> acpi shutdown %s %s' % (slave_name, vm_type, vm_id))
|
||||||
|
|
||||||
if vm_type == 'kvm':
|
if vm_type == 'kvm':
|
||||||
result = proxobject.nodes(slave_name).qemu(vm_id).status.stop.post()
|
result = proxobject.nodes(slave_name).qemu(vm_id).status.stop.post()
|
||||||
if vm_type == 'lxc':
|
if vm_type == 'lxc':
|
||||||
result = proxobject.nodes(slave_name).lxc(vm_id).status.stop.post()
|
result = proxobject.nodes(slave_name).lxc(vm_id).status.stop.post()
|
||||||
#ioconfig.logger.info('grid[{}]> {}'.format(slave_name, result))
|
#ioconfig.logger.info('slave[{}]> {}'.format(slave_name, result))
|
||||||
response = { 'status':'SHUTDOWN', 'vmid':vm_id }
|
response = { 'status':'SHUTDOWN', 'vmid':vm_id }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -182,13 +182,13 @@ def vmstop(vm_id):
|
||||||
proxobject = auth(slave_id)
|
proxobject = auth(slave_id)
|
||||||
vm_type = vm_type.lower()
|
vm_type = vm_type.lower()
|
||||||
slave_name = proxobject.cluster.status.get()[0]['name']
|
slave_name = proxobject.cluster.status.get()[0]['name']
|
||||||
ioconfig.logger.info('grid[%s]> power off %s %s' % (slave_name, vm_type, vm_id))
|
ioconfig.logger.info('slave[%s]> power off %s %s' % (slave_name, vm_type, vm_id))
|
||||||
|
|
||||||
if vm_type == 'kvm':
|
if vm_type == 'kvm':
|
||||||
result = proxobject.nodes(slave_name).qemu(vm_id).status.stop.post()
|
result = proxobject.nodes(slave_name).qemu(vm_id).status.stop.post()
|
||||||
if vm_type == 'lxc':
|
if vm_type == 'lxc':
|
||||||
result = proxobject.nodes(slave_name).lxc(vm_id).status.stop.post()
|
result = proxobject.nodes(slave_name).lxc(vm_id).status.stop.post()
|
||||||
#ioconfig.logger.info('grid[{}]> {}'.format(slave_name, result))
|
#ioconfig.logger.info('slave[{}]> {}'.format(slave_name, result))
|
||||||
response = { 'status':'STOP', 'vmid':vm_id }
|
response = { 'status':'STOP', 'vmid':vm_id }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -199,13 +199,13 @@ def vmshutdown(vm_id):
|
||||||
proxobject = auth(slave_id)
|
proxobject = auth(slave_id)
|
||||||
vm_type = vm_type.lower()
|
vm_type = vm_type.lower()
|
||||||
slave_name = proxobject.cluster.status.get()[0]['name']
|
slave_name = proxobject.cluster.status.get()[0]['name']
|
||||||
ioconfig.logger.info('grid[%s]> acpi shutdown sent to %s %s' % (slave_name, vm_type, vm_id))
|
ioconfig.logger.info('slave[%s]> acpi shutdown sent to %s %s' % (slave_name, vm_type, vm_id))
|
||||||
|
|
||||||
if vm_type == 'kvm':
|
if vm_type == 'kvm':
|
||||||
result = proxobject.nodes(slave_name).qemu(vm_id).status.shutdown.post()
|
result = proxobject.nodes(slave_name).qemu(vm_id).status.shutdown.post()
|
||||||
if vm_type == 'lxc':
|
if vm_type == 'lxc':
|
||||||
result = proxobject.nodes(slave_name).lxc(vm_id).status.shutdown.post()
|
result = proxobject.nodes(slave_name).lxc(vm_id).status.shutdown.post()
|
||||||
#ioconfig.logger.info('grid[{}]> {}'.format(slave_name, result))
|
#ioconfig.logger.info('slave[{}]> {}'.format(slave_name, result))
|
||||||
response = { 'status':'SHUTDOWN', 'vmid':vm_id }
|
response = { 'status':'SHUTDOWN', 'vmid':vm_id }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -216,13 +216,13 @@ def vmsuspend(vm_id):
|
||||||
proxobject = auth(slave_id)
|
proxobject = auth(slave_id)
|
||||||
vm_type = vm_type.lower()
|
vm_type = vm_type.lower()
|
||||||
slave_name = proxobject.cluster.status.get()[0]['name']
|
slave_name = proxobject.cluster.status.get()[0]['name']
|
||||||
ioconfig.logger.info('grid[%s]> suspending %s %s' % (slave_name, vm_type, vm_id))
|
ioconfig.logger.info('slave[%s]> suspending %s %s' % (slave_name, vm_type, vm_id))
|
||||||
|
|
||||||
if vm_type == 'kvm':
|
if vm_type == 'kvm':
|
||||||
result = proxobject.nodes(slave_name).qemu(vm_id).status.suspend.post()
|
result = proxobject.nodes(slave_name).qemu(vm_id).status.suspend.post()
|
||||||
if vm_type == 'lxc':
|
if vm_type == 'lxc':
|
||||||
result = proxobject.nodes(slave_name).lxc(vm_id).status.suspend.post()
|
result = proxobject.nodes(slave_name).lxc(vm_id).status.suspend.post()
|
||||||
#ioconfig.logger.info('grid[{}]> {}'.format(slave_name, result))
|
#ioconfig.logger.info('slave[{}]> {}'.format(slave_name, result))
|
||||||
response = { 'status':'SUSPEND', 'vmid':vm_id }
|
response = { 'status':'SUSPEND', 'vmid':vm_id }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -233,13 +233,13 @@ def vmresume(vm_id):
|
||||||
proxobject = auth(slave_id)
|
proxobject = auth(slave_id)
|
||||||
vm_type = vm_type.lower()
|
vm_type = vm_type.lower()
|
||||||
slave_name = proxobject.cluster.status.get()[0]['name']
|
slave_name = proxobject.cluster.status.get()[0]['name']
|
||||||
ioconfig.logger.info('grid[%s]> resuming %s %s' % (slave_name, vm_type, vm_id))
|
ioconfig.logger.info('slave[%s]> resuming %s %s' % (slave_name, vm_type, vm_id))
|
||||||
|
|
||||||
if vm_type == 'kvm':
|
if vm_type == 'kvm':
|
||||||
result = proxobject.nodes(slave_name).qemu(vm_id).status.resume.post()
|
result = proxobject.nodes(slave_name).qemu(vm_id).status.resume.post()
|
||||||
if vm_type == 'lxc':
|
if vm_type == 'lxc':
|
||||||
result = proxobject.nodes(slave_name).lxc(vm_id).status.resume.post()
|
result = proxobject.nodes(slave_name).lxc(vm_id).status.resume.post()
|
||||||
#ioconfig.logger.info('grid[{}]> {}'.format(slave_name, result))
|
#ioconfig.logger.info('slave[{}]> {}'.format(slave_name, result))
|
||||||
response = { 'status':'RESUME', 'vmid':vm_id }
|
response = { 'status':'RESUME', 'vmid':vm_id }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -250,7 +250,7 @@ def vmvnc(vm_id):
|
||||||
proxobject = auth(slave_id)
|
proxobject = auth(slave_id)
|
||||||
vm_type = vm_type.lower()
|
vm_type = vm_type.lower()
|
||||||
slave_name = proxobject.cluster.status.get()[0]['name']
|
slave_name = proxobject.cluster.status.get()[0]['name']
|
||||||
ioconfig.logger.info('grid[%s]> invoking vnc ticket for %s %s' % (slave_name, vm_type, vm_id))
|
ioconfig.logger.info('slave[%s]> invoking vnc ticket for %s %s' % (slave_name, vm_type, vm_id))
|
||||||
|
|
||||||
if vm_type == 'kvm':
|
if vm_type == 'kvm':
|
||||||
ticket = proxobject.nodes(slave_name).qemu(vm_id).vncproxy.post(websocket=1)
|
ticket = proxobject.nodes(slave_name).qemu(vm_id).vncproxy.post(websocket=1)
|
||||||
|
@ -282,7 +282,7 @@ def vmvnc(vm_id):
|
||||||
external_url = ioconfig.parser.get('general', 'novnc_url')
|
external_url = ioconfig.parser.get('general', 'novnc_url')
|
||||||
prefix = external_url + "/?host=" + myip + "&port=" + listenport + "&encrypt=0&true_color=1&password="
|
prefix = external_url + "/?host=" + myip + "&port=" + listenport + "&encrypt=0&true_color=1&password="
|
||||||
vnc_url = prefix + ticket['ticket']
|
vnc_url = prefix + ticket['ticket']
|
||||||
ioconfig.logger.info('grid[{}]> {}'.format(slave_name, vnc_url))
|
ioconfig.logger.info('slave[{}]> {}'.format(slave_name, vnc_url))
|
||||||
|
|
||||||
response = { 'status':'VNC', 'fqdn':external_url, 'host':myip, 'port':listenport, 'encrypt':'0', 'true_color':'1', 'ticket':ticket['ticket'] }
|
response = { 'status':'VNC', 'fqdn':external_url, 'host':myip, 'port':listenport, 'encrypt':'0', 'true_color':'1', 'ticket':ticket['ticket'] }
|
||||||
return response
|
return response
|
||||||
|
|
23
utils.py
23
utils.py
|
@ -2,8 +2,27 @@
|
||||||
#
|
#
|
||||||
# helper functions
|
# helper functions
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
import functools
|
import functools
|
||||||
|
from copy import deepcopy
|
||||||
|
from random import SystemRandom
|
||||||
|
|
||||||
|
|
||||||
|
def genpassword(length=20):
|
||||||
|
""" generates pseudo-random password """
|
||||||
|
choice = SystemRandom().choice
|
||||||
|
charsets = [
|
||||||
|
'abcdefghijklmnopqrstuvwxyz',
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||||
|
'0123456789',
|
||||||
|
'%&=?+~#-_.',
|
||||||
|
]
|
||||||
|
pwd = []
|
||||||
|
charset = choice(charsets)
|
||||||
|
while len(pwd) < length:
|
||||||
|
pwd.append(choice(charset))
|
||||||
|
charset = choice(list(set(charsets) - set([charset])))
|
||||||
|
return "".join(pwd)
|
||||||
|
|
||||||
|
|
||||||
def dict_merge(target, *args):
|
def dict_merge(target, *args):
|
||||||
""" Recursively merges mutiple dicts """
|
""" Recursively merges mutiple dicts """
|
||||||
|
@ -24,6 +43,7 @@ def dict_merge(target, *args):
|
||||||
target[k] = deepcopy(v)
|
target[k] = deepcopy(v)
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
def find_rec(search_dict, field):
|
def find_rec(search_dict, field):
|
||||||
"""
|
"""
|
||||||
Takes a dict with nested lists and dicts,
|
Takes a dict with nested lists and dicts,
|
||||||
|
@ -68,3 +88,4 @@ def chained_get(dct, *keys):
|
||||||
return 'NA' if level is SENTRY else level.get(key, SENTRY)
|
return 'NA' if level is SENTRY else level.get(key, SENTRY)
|
||||||
return functools.reduce(getter, keys, dct)
|
return functools.reduce(getter, keys, dct)
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue