[pve-devel] [PATCH v2 manager 5/5] ui: support u2f authentication and configuration

Dominik Csapak d.csapak at proxmox.com
Tue Apr 2 15:37:31 CEST 2019


lgtm, some nits/questions inline

On 4/2/19 12:22 PM, Wolfgang Bumiller wrote:
> Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
> ---
>   PVE/HTTPServer.pm                  |   2 +-
>   www/index.html.tpl                 |   2 +
>   www/manager6/Makefile              |   1 +
>   www/manager6/Workspace.js          |   6 +-
>   www/manager6/dc/TFAEdit.js         | 463 +++++++++++++++++++++++++++++++++++++
>   www/manager6/dc/UserView.js        |  15 +-
>   www/manager6/window/LoginWindow.js | 121 +++++++---
>   7 files changed, 577 insertions(+), 33 deletions(-)
>   create mode 100644 www/manager6/dc/TFAEdit.js
> 
> diff --git a/PVE/HTTPServer.pm b/PVE/HTTPServer.pm
> index ec970010..ec57cd09 100755
> --- a/PVE/HTTPServer.pm
> +++ b/PVE/HTTPServer.pm
> @@ -85,7 +85,7 @@ sub auth_handler {
>   	if (defined($challenge)) {
>   	    $rpcenv->set_u2f_challenge($challenge);
>   	    die "No ticket\n"
> -		if ($rel_uri ne '/access/u2f' || $method ne 'POST');
> +		if ($rel_uri ne '/access/tfa' || $method ne 'POST');
>   	}
>   
>   	$rpcenv->set_user($username);
> diff --git a/www/index.html.tpl b/www/index.html.tpl
> index ae7f610f..2cb6d0c7 100644
> --- a/www/index.html.tpl
> +++ b/www/index.html.tpl
> @@ -22,6 +22,8 @@
>       [%- ELSE %]
>       <script type="text/javascript" src="/pve2/ext6/ext-all.js"></script>
>       <script type="text/javascript" src="/pve2/ext6/charts.js"></script>
> +    <script type="text/javascript" src="/pve2/js/u2f-api.js"></script>
> +    <script type="text/javascript" src="/pve2/js/qrcode.min.js"></script>
>       [% END %]
>       <script type="text/javascript">
>       Proxmox = {
> diff --git a/www/manager6/Makefile b/www/manager6/Makefile
> index 5ad70933..853fcb4f 100644
> --- a/www/manager6/Makefile
> +++ b/www/manager6/Makefile
> @@ -200,6 +200,7 @@ JSSRC= 				                 	\
>   	dc/Guests.js					\
>   	dc/OptionView.js				\
>   	dc/StorageView.js				\
> +	dc/TFAEdit.js					\
>   	dc/UserEdit.js					\
>   	dc/UserView.js					\
>   	dc/PoolView.js					\
> diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js
> index e88300f2..1d343525 100644
> --- a/www/manager6/Workspace.js
> +++ b/www/manager6/Workspace.js
> @@ -19,8 +19,7 @@ Ext.define('PVE.Workspace', {
>       updateLoginData: function(loginData) {
>   	var me = this;
>   	me.loginData = loginData;
> -	Proxmox.CSRFPreventionToken = loginData.CSRFPreventionToken;
> -	Proxmox.UserName = loginData.username;
> +	Proxmox.Utils.setAuthData(loginData);
>   
>   	var rt = me.down('pveResourceTree');
>   	rt.setDatacenterText(loginData.clustername);
> @@ -29,9 +28,6 @@ Ext.define('PVE.Workspace', {
>   	    Ext.state.Manager.set('GuiCap', loginData.cap);
>   	}
>   
> -	// creates a session cookie (expire = null)
> -	// that way the cookie gets deleted after browser window close
> -	Ext.util.Cookies.set('PVEAuthCookie', loginData.ticket, null, '/', null, true);
>   	me.onLogin(loginData);
>       },
>   
> diff --git a/www/manager6/dc/TFAEdit.js b/www/manager6/dc/TFAEdit.js
> new file mode 100644
> index 00000000..c92afa6b
> --- /dev/null
> +++ b/www/manager6/dc/TFAEdit.js
> @@ -0,0 +1,463 @@
> +Ext.define('PVE.window.TFAEdit', {
> +    extend: 'Ext.window.Window',
> +    mixins: ['Proxmox.Mixin.CBind'],
> +
> +    modal: true,
> +    resizable: false,
> +    title: gettext('Two Factor Authentication'),
> +    subject: 'TFA',
> +    url: '/api2/extjs/access/tfa',
> +    width: 512,
> +
> +    layout: {
> +	type: 'vbox',
> +	align: 'stretch'
> +    },
> +
> +    updateQrCode: function() {
> +	var me = this;
> +	var values = me.lookup('totp-form').getValues();
> +	var algorithm = values.algorithm;
> +	if (!algorithm) {
> +	    algorithm = 'SHA1';
> +	}
> +
> +	me.qrcode.makeCode(
> +	    'otpauth://totp/' + encodeURIComponent(values.name) +
> +	    '?secret=' + values.secret +
> +	    '&period=' + values.step +
> +	    '&digits=' + values.digits +
> +	    '&algorithm=' + algorithm +
> +	    '&issuer=' + encodeURIComponent(values.issuer)
> +	);
> +
> +	me.lookup('challenge').setVisible(true);
> +	me.down('#qrbox').setVisible(true);

nit: it would be nicer if the lookups were consistent (e.g. only .lookup 
or only .down)
in this case it would probably also be possible to use binds and the 
viewmodel i guess

> +    },
> +
> +    showError: function(error) {
> +	var ErrorNames = {
> +	    '1': gettext('Other Error'),
> +	    '2': gettext('Bad Request'),
> +	    '3': gettext('Configuration Unsupported'),
> +	    '4': gettext('Device Ineligible'),
> +	    '5': gettext('Timeout')
> +	};
> +	Ext.Msg.alert(
> +	    gettext('Error'),
> +	    "U2F Error: " + (ErrorNames[error] || Proxmox.Utils.unknownText)
> +	);
> +    },
> +
> +    doU2FChallenge: function(response) {
> +	var me = this;
> +
> +	var data = response.result.data;
> +	me.lookup('password').setDisabled(true);
> +	var msg = Ext.Msg.show({
> +	    title: 'U2F: '+gettext('Setup'),
> +	    message: gettext('Please press the button on your U2F Device'),
> +	    buttons: []
> +	});
> +	Ext.Function.defer(function() {
> +	    u2f.register(data.appId, [data], [], function(data) {
> +		msg.close();
> +		if (data.errorCode) {
> +		    me.showError(data.errorCode);
> +		} else {
> +		    me.respondToU2FChallenge(data);
> +		}
> +	    });
> +	}, 500, me);
> +    },
> +
> +    respondToU2FChallenge: function(data) {
> +	var me = this;
> +	var params = {
> +	    userid: me.userid,
> +	    action: 'confirm',
> +	    response: JSON.stringify(data)
> +	};
> +	if (Proxmox.UserName !== 'root at pam') {
> +	    params.password = me.lookup('password').value;
> +	}
> +	Proxmox.Utils.API2Request({
> +	    url: '/api2/extjs/access/tfa',
> +	    params: params,
> +	    method: 'PUT',
> +	    success: function() {
> +		me.close();
> +		Ext.Msg.show({
> +		    title: gettext('Success'),
> +		    message: gettext('U2F Device successfully connected.'),
> +		    buttons: Ext.Msg.OK
> +		});
> +	    },
> +	    failure: function(response, opts) {
> +		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
> +	    }
> +	});
> +    },
> +
> +    viewModel: {
> +	data: {
> +	    in_totp_tab: true,
> +	    tfa_required: false,
> +	    u2f_available: true,
> +	}
> +    },
> +
> +    afterLoadingRealm: function(realm_tfa_type) {
> +	var me = this;
> +	var viewmodel = me.getViewModel();
> +	if (!realm_tfa_type) {
> +	    // There's no TFA enforced by the realm, everything works.
> +	    viewmodel.set('u2f_available', true);
> +	    viewmodel.set('tfa_required', false);
> +	} else if (realm_tfa_type === 'oath') {
> +	    // The realm explicitly requires TOTP
> +	    viewmodel.set('tfa_required', true);
> +	    viewmodel.set('u2f_available', false);
> +	} else {
> +	    // The realm enforces some other TFA type (yubico)
> +	    me.close();
> +	    Ext.Msg.alert(
> +		gettext('Error'),
> +		Ext.String.format(
> +		    gettext("Custom 2nd factor configuration is not supported on realms with '{0}' TFA."),
> +		    realm_tfa_type
> +		)
> +	    );
> +	}
> +	//me.lookup('delete-button').setDisabled(has_tfa_configured);
> +	//me.lookup('u2f-panel').setDisabled(has_tfa_configured);

i guess this is a leftover from before using bindings

> +    },
> +
> +    controller: {
> +	xclass: 'Ext.app.ViewController',
> +	control: {
> +	    'field[qrupdate=true]': {
> +		change: function() {
> +		    var me = this.getView();
> +		    me.updateQrCode();
> +		}
> +	    },
> +	    '#': {
> +		show: function() {
> +		    var me = this.getView();
> +		    me.down('#qrbox').getEl().appendChild(me.qrdiv);
> +		    me.down('#qrbox').setVisible(false);
> +
> +		    if (Proxmox.UserName === 'root at pam') {
> +			me.lookup('password').setVisible(false);
> +			me.lookup('password').setDisabled(true);
> +		    }
> +		    me.lookup('challenge').setVisible(false);
> +		}
> +	    },
> +	    '#tfatabs': {
> +		tabchange: function(panel, newcard) {
> +		    var viewmodel = this.getViewModel();
> +		    viewmodel.set('in_totp_tab', newcard.itemId === 'totp-panel');
> +		}
> +	    }
> +	},
> +
> +	applySettings: function() {
> +	    var me = this;
> +	    var values = me.lookup('totp-form').getValues();
> +	    var params = {
> +		userid: me.getView().userid,
> +		action: 'new',
> +		key: values.secret,
> +		config: PVE.Parser.printPropertyString({
> +		    type: 'oath',
> +		    digits: values.digits,
> +		    step: values.step,
> +		}),
> +		// this is used to verify that the client generates the correct codes:
> +		response: me.lookup('challenge').value,
> +	    };
> +
> +	    if (Proxmox.UserName !== 'root at pam') {
> +		params.password = me.lookup('password').value;
> +	    }
> +
> +	    Proxmox.Utils.API2Request({
> +		url: '/api2/extjs/access/tfa',
> +		params: params,
> +		method: 'PUT',
> +		waitMsgTarget: me.getView(),
> +		success: function(response, opts) {
> +		    me.getView().close();
> +		},
> +		failure: function(response, opts) {
> +		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
> +		}
> +	    });
> +	},
> +
> +	deleteTFA: function() {
> +	    var me = this;
> +	    var values = me.lookup('totp-form').getValues();
> +	    var params = {
> +		userid: me.getView().userid,
> +		action: 'delete',
> +	    };
> +
> +	    if (Proxmox.UserName !== 'root at pam') {
> +		params.password = me.lookup('password').value;
> +	    }
> +
> +	    Proxmox.Utils.API2Request({
> +		url: '/api2/extjs/access/tfa',
> +		params: params,
> +		method: 'PUT',
> +		waitMsgTarget: me.getView(),
> +		success: function(response, opts) {
> +		    me.getView().close();
> +		},
> +		failure: function(response, opts) {
> +		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
> +		}
> +	    });
> +	},
> +
> +	randomizeSecret: function() {
> +	    var me = this;
> +	    var rnd = new Uint8Array(16);
> +	    window.crypto.getRandomValues(rnd);
> +	    var data = '';
> +	    rnd.forEach(function(b) {
> +		// just use the first 5 bit
> +		b = b & 0x1f;
> +		if (b < 26) {
> +		    // A..Z
> +		    data += String.fromCharCode(b + 0x41);
> +		} else {
> +		    // 2..7
> +		    data += String.fromCharCode(b-26 + 0x32);
> +		}
> +	    });
> +	    me.lookup('tfa-secret').setValue(data);
> +	},
> +
> +	startU2FRegistration: function() {
> +	    var me = this;
> +
> +	    var params = {
> +		userid: me.getView().userid,
> +		action: 'new'
> +	    };
> +
> +	    if (Proxmox.UserName !== 'root at pam') {
> +		params.password = me.lookup('password').value;
> +	    }
> +
> +	    Proxmox.Utils.API2Request({
> +		url: '/api2/extjs/access/tfa',
> +		params: params,
> +		method: 'PUT',
> +		waitMsgTarget: me.getView(),
> +		success: function(response) {
> +		    me.getView().doU2FChallenge(response);
> +		},
> +		failure: function(response, opts) {
> +		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
> +		}
> +	    });
> +	}
> +    },
> +
> +    items: [
> +	{
> +	    xtype: 'tabpanel',
> +	    itemId: 'tfatabs',
> +	    border: false,
> +	    items: [
> +		{
> +		    xtype: 'panel',
> +		    title: 'TOTP',
> +		    itemId: 'totp-panel',
> +		    border: false,
> +		    layout: {
> +			type: 'vbox',
> +			align: 'stretch'
> +		    },
> +		    items: [
> +			{
> +			    xtype: 'form',
> +			    layout: 'anchor',
> +			    border: false,
> +			    reference: 'totp-form',
> +			    fieldDefaults: {
> +				labelWidth: 120,
> +				anchor: '100%',
> +				padding: '0 5',
> +			    },
> +			    items: [
> +				{
> +				    xtype: 'textfield',
> +				    fieldLabel: gettext('Secret'),
> +				    name: 'secret',
> +				    reference: 'tfa-secret',
> +				    validateValue: function(value) {
> +					return value.match(/^[A-Z2-7=]$/);
> +				    },
> +				    qrupdate: true,
> +				    padding: '5 5',
> +				},
> +				{
> +				    xtype: 'numberfield',
> +				    fieldLabel: gettext('Time period'),
> +				    name: 'step',
> +				    value: 30,
> +				    minValue: 10,
> +				    qrupdate: true,
> +				},
> +				{
> +				    xtype: 'numberfield',
> +				    fieldLabel: gettext('Digits'),
> +				    name: 'digits',
> +				    value: 6,
> +				    minValue: 6,
> +				    maxValue: 8,
> +				    qrupdate: true,
> +				},
> +				{
> +				    xtype: 'textfield',
> +				    fieldLabel: gettext('Issuer Name'),
> +				    name: 'issuer',
> +				    value: 'Proxmox Web UI',
> +				    qrupdate: true,
> +				},
> +				{
> +				    xtype: 'textfield',
> +				    fieldLabel: gettext('Account Name'),
> +				    name: 'name',
> +				    cbind: {
> +					value: '{userid}',
> +				    },
> +				    qrupdate: true,
> +				}
> +			    ]
> +			},
> +			{
> +			    xtype: 'box',
> +			    itemId: 'qrbox',
> +			    visible: false, // will be enabled when generating a qr code
> +			    style: {
> +				'background-color': 'white',
> +				padding: '5px',
> +				width: '266px',
> +				height: '266px',
> +			    }
> +			},
> +			{
> +			    xtype: 'textfield',
> +			    fieldLabel: gettext('Code'),
> +			    labelWidth: 120,
> +			    reference: 'challenge',
> +			    padding: '0 5',
> +			    emptyText: gettext('verify TOTP authentication code')
> +			}
> +		    ]
> +		},
> +		{
> +		    title: 'U2F',
> +		    itemId: 'u2f-panel',
> +		    reference: 'u2f-panel',
> +		    border: false,
> +		    padding: '5 5',
> +		    layout: {
> +			type: 'vbox',
> +			align: 'middle'
> +		    },
> +		    bind: {
> +			disabled: '{!u2f_available}'
> +		    },
> +		    items: [
> +			{
> +			    xtype: 'label',
> +			    width: 500,
> +			    text: gettext('To register a U2F device, connect the device, then click the button and follow the instructions.'),
> +			}
> +		    ]
> +		}
> +	    ]
> +	},
> +	{
> +	    xtype: 'textfield',
> +	    inputType: 'password',
> +	    fieldLabel: gettext('Password'),
> +	    minLength: 5,
> +	    reference: 'password',
> +	    padding: '0 5',
> +	    labelWidth: 120,
> +	    emptyText: gettext('verify current password')
> +	}
> +    ],
> +
> +    buttons: [
> +	{
> +	    text: gettext('Randomize'),
> +	    reference: 'randomize-button',
> +	    handler: 'randomizeSecret',
> +	    bind: {
> +		hidden: '{!in_totp_tab}',
> +		disabled: '{!user_tfa}'
> +	    }
> +	},
> +	{
> +	    text: gettext('Apply'),
> +	    handler: 'applySettings',
> +	    bind: {
> +		hidden: '{!in_totp_tab}',
> +		disabled: '{!user_tfa}'
> +	    }
> +	},
> +	{
> +	    xtype: 'button',
> +	    text: gettext('Register U2F Device'),
> +	    handler: 'startU2FRegistration',
> +	    bind: {
> +		hidden: '{in_totp_tab}'
> +	    }
> +	},
> +	{
> +	    text: gettext('Delete'),
> +	    reference: 'delete-button',
> +	    handler: 'deleteTFA',
> +	    bind: {
> +		disabled: '{tfa_required}'
> +	    }
> +	}
> +    ],
> +
> +    initComponent: function() {
> +	var me = this;
> +
> +	me.qrdiv = document.createElement('center');
> +	me.qrcode = new QRCode(me.qrdiv, {
> +	    //text: "This is not the qr code you're looking for",
> +	    width: 256,
> +	    height: 256,
> +	    correctLevel: QRCode.CorrectLevel.M
> +	});
> +
> +	var store = new Ext.data.Store({
> +	    model: 'pve-domains',
> +	    autoLoad: true
> +	});
> +
> +	store.on('load', function() {
> +	    var user_realm = me.userid.split('@')[1];
> +	    var realm = me.store.findRecord('realm', user_realm);
> +	    me.afterLoadingRealm(realm && realm.data && realm.data.tfa);
> +	}, me);
> +
> +	Ext.apply(me, { store: store });
> +
> +	me.callParent();

you could skip the complete initcomponent function, if you statically
use the store (without autoload) and create the qrdiv and load the store 
in the controllers 'init' function (this will be called directly after 
initComponent)

> +    }
> +});
> diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js
> index 4d0c5595..f5c830ec 100644
> --- a/www/manager6/dc/UserView.js
> +++ b/www/manager6/dc/UserView.js
> @@ -78,6 +78,19 @@ Ext.define('PVE.dc.UserView', {
>   	    }
>   	});
>   
> +	var tfachange_btn = new Proxmox.button.Button({
> +	    text: gettext('TFA'),

above 'TFA' is not in gettext, here it is, do we want to translate that?

> +	    disabled: true,
> +	    selModel: sm,
> +	    handler: function(btn, event, rec) {
> +		var win = Ext.create('PVE.window.TFAEdit',{
> +                    userid: rec.data.userid
> +		});
> +		win.on('destroy', reload);
> +		win.show();
> +	    }
> +	});
> +
>           var tbar = [
>               {
>   		text: gettext('Add'),
> @@ -89,7 +102,7 @@ Ext.define('PVE.dc.UserView', {
>                       win.show();
>   		}
>               },
> -	    edit_btn, remove_btn, pwchange_btn
> +	    edit_btn, remove_btn, pwchange_btn, tfachange_btn
>           ];
>   
>   	var render_username = function(userid) {
> diff --git a/www/manager6/window/LoginWindow.js b/www/manager6/window/LoginWindow.js
> index 5967c92f..93b06061 100644
> --- a/www/manager6/window/LoginWindow.js
> +++ b/www/manager6/window/LoginWindow.js
> @@ -13,39 +13,108 @@ Ext.define('PVE.window.LoginWindow', {
>   	    var saveunField = this.lookupReference('saveunField');
>   	    var view = this.getView();
>   
> -	    if(form.isValid()){
> -		view.el.mask(gettext('Please wait...'), 'x-mask-loading');
> +	    if (!form.isValid()) {
> +		return;
> +	    }
> +
> +	    var perform_u2f_fn;
> +	    var finish_u2f_fn;

i guess you do this because of jslint?
because you do not have to define the variables before using (var works 
on function level)

alternatievely those functions could also be real functions in the 
controller? or do you need something from the closure

> +
> +	    var failure_fn = function(resp) {
> +		view.el.unmask();
> +		var handler = function() {
> +		    var uf = me.lookupReference('usernameField');
> +		    uf.focus(true, true);
> +		};
> +
> +		Ext.MessageBox.alert(gettext('Error'),
> +				     gettext("Login failed. Please try again"),
> +				     handler);
> +	    };
> +
> +	    var success_fn = function(data) {
> +		var handler = view.handler || Ext.emptyFn;
> +		handler.call(me, data);
> +		view.close();
> +	    };
> +
> +	    view.el.mask(gettext('Please wait...'), 'x-mask-loading');
> +
> +	    // set or clear username
> +	    var sp = Ext.state.Manager.getProvider();
> +	    if (saveunField.getValue() === true) {
> +		sp.set(unField.getStateId(), unField.getValue());
> +	    } else {
> +		sp.clear(unField.getStateId());
> +	    }
> +	    sp.set(saveunField.getStateId(), saveunField.getValue());
> +
> +	    form.submit({
> +		failure: function(f, resp){
> +		    failure_fn(resp);
> +		},
> +		success: function(f, resp){
> +		    view.el.unmask();
>   
> -		// set or clear username
> -		var sp = Ext.state.Manager.getProvider();
> -		if (saveunField.getValue() === true) {
> -		    sp.set(unField.getStateId(), unField.getValue());
> -		} else {
> -		    sp.clear(unField.getStateId());
> +		    var data = resp.result.data;
> +		    if (Ext.isDefined(data.U2FChallenge)) {
> +			perform_u2f_fn(data);
> +		    } else {
> +			success_fn(data);
> +		    }
>   		}
> -		sp.set(saveunField.getStateId(), saveunField.getValue());
> +	    });
> +
> +	    perform_u2f_fn = function(data) {
> +		// Store first factor login information first:
> +		data.LoggedOut = true;
> +		Proxmox.Utils.setAuthData(data);
> +		// Show the message:
> +		var msg = Ext.Msg.show({
> +		    title: 'U2F: '+gettext('Verification'),
> +		    message: gettext('Please press the button on your U2F Device'),
> +		    buttons: []
> +		});
> +		var chlg = data.U2FChallenge;
> +		var key = {
> +		    version: chlg.version,
> +		    keyHandle: chlg.keyHandle
> +		};
> +		u2f.sign(chlg.appId, chlg.challenge, [key], function(res) {
> +		    msg.close();
> +		    if (res.errorCode) {
> +			Proxmox.Utils.authClear();
> +			Ext.Msg.alert(gettext('Error'), "U2F Error: "+res.errorCode);
> +			return;
> +		    }
> +		    delete res.errorCode;
> +		    finish_u2f_fn(res);
> +		});
> +	    };
>   
> -		form.submit({
> -		    failure: function(f, resp){
> +	    finish_u2f_fn = function(res) {
> +		view.el.mask(gettext('Please wait...'), 'x-mask-loading');
> +		var params = { response: JSON.stringify(res) };
> +		Proxmox.Utils.API2Request({
> +		    url: '/api2/extjs/access/tfa',
> +		    params: params,
> +		    method: 'POST',
> +		    timeout: 5000, // it'll delay both success & failure
> +		    success: function(resp, opts) {
>   			view.el.unmask();
> -			var handler = function() {
> -			    var uf = me.lookupReference('usernameField');
> -			    uf.focus(true, true);
> -			};
> -
> -			Ext.MessageBox.alert(gettext('Error'),
> -					     gettext("Login failed. Please try again"),
> -					     handler);
> +			// Fill in what we copy over from the 1st factor:
> +			var data = resp.result.data;
> +			data.CSRFPreventionToken = Proxmox.CSRFPreventionToken;
> +			data.username = Proxmox.UserName;
> +			// Finish logging in:
> +			success_fn(data);
>   		    },
> -		    success: function(f, resp){
> -			view.el.unmask();
> -
> -			var handler = view.handler || Ext.emptyFn;
> -			handler.call(me, resp.result.data);
> -			view.close();
> +		    failure: function(resp, opts) {
> +			Proxmox.Utils.authClear();
> +			failure_fn(resp);
>   		    }
>   		});
> -	    }
> +	    };
>   	},
>   
>   	control: {
> 





More information about the pve-devel mailing list