commit 93b5bf300a14d6f21eb08c8af855d5dd2aefcade Author: sergiotarxz Date: Tue Jul 16 23:49:24 2024 +0200 Initial commit. diff --git a/Build.PL b/Build.PL new file mode 100755 index 0000000..c6027df --- /dev/null +++ b/Build.PL @@ -0,0 +1,28 @@ +#!/usr/bin/env perl +use Module::Build; + +my $home = $ENV{HOME}; + +my $build = Module::Build->new( + module_name => 'VPNManager', + license => 'AGPLv3', + dist_author => 'Sergio Iglesias ', + dist_abstract => 'Manage Wireguard.', + requires => { + 'DBI' => 0, + 'DBD::SQLite' => 0, + 'Path::Tiny' => 0, + 'DBIx::Class' => 0, + 'Mojolicious' => 0, + 'Moo' => 0, + 'Crypt::Bcrypt' => 0, + 'Crypt::URandom' => 0, + 'Capture::Tiny' => 0, + }, + test_requires => { + 'Test::MockModule' => 0, + 'Test::Most' => 0, + 'Test::MockObject' => 0, + } +); +$build->create_build_script; diff --git a/lib/VPNManager.pm b/lib/VPNManager.pm new file mode 100644 index 0000000..5d7d600 --- /dev/null +++ b/lib/VPNManager.pm @@ -0,0 +1,70 @@ +package VPNManager; + +use v5.38.2; + +use strict; +use warnings; + +use Mojo::Base 'Mojolicious', -signatures; + +use Path::Tiny; + +# This method will run once at server start +sub startup ($self) { + + # Load configuration from config file + system 'chmod', '600', path(__FILE__)->parent->parent->child('v_p_n_manager.yml'); + my $config = $self->plugin('NotYAMLConfig'); + + # Configure the application + $self->secrets( $config->{secrets} ); + + # Router + my $r = $self->routes; + + # Normal route to controller + my $routes = $r->under( + '/', + sub { + my $c = shift; + my $redirect_login = sub { + my $c = shift; + my $url = Mojo::URL->new('/login'); + $url->query( redirect_to => $c->url_for ); + $c->redirect_to($url); + return 0; + }; + + if ( $c->url_for->path =~ /^\/login\/?$/ ) { + return 1; + } + if ( !defined $c->session->{user} ) { + return $redirect_login->($c); + } + require VPNManager::Schema; + my $schema = VPNManager::Schema->Schema; + my $resultset_admins = $schema->resultset('Admin'); + my ($user) = $resultset_admins->search( + { + username => $c->session->{user}, + } + ); + if ( !defined $user ) { + delete $c->session->{user}; + return $redirect_login->($c); + } + return 1; + } + ); + $routes->get('/')->to('Main#main'); + $routes->get('/login')->to('Main#login'); + $routes->post('/login')->to('Main#login_post'); + $routes->get('/vpn/create-user')->to('Main#create_vpn_user'); + $routes->post('/vpn/create-user')->to('Main#create_vpn_user_post'); + $routes->get('/vpn/user/:id/details')->to('Main#show_user_details'); + $routes->post('/vpn/user/:id/download')->to('Main#download_file'); + $routes->post('/vpn/user/:id/enable')->to('Main#enable_user'); + $routes->post('/vpn/user/:id/disable')->to('Main#disable_user'); + # $routes->post('/vpn/save')->to('Main#save_vpn_settings'); +} +1; diff --git a/lib/VPNManager/Controller/Example.pm b/lib/VPNManager/Controller/Example.pm new file mode 100644 index 0000000..439bed2 --- /dev/null +++ b/lib/VPNManager/Controller/Example.pm @@ -0,0 +1,11 @@ +package VPNManager::Controller::Example; +use Mojo::Base 'Mojolicious::Controller', -signatures; + +# This action will render a template +sub welcome ($self) { + + # Render template "example/welcome.html.ep" with message + $self->render(msg => 'Welcome to the Mojolicious real-time web framework!'); +} + +1; diff --git a/lib/VPNManager/Controller/Main.pm b/lib/VPNManager/Controller/Main.pm new file mode 100644 index 0000000..9c05afa --- /dev/null +++ b/lib/VPNManager/Controller/Main.pm @@ -0,0 +1,188 @@ +package VPNManager::Controller::Main; + +use v5.38.2; + +use strict; +use warnings; + +use Crypt::Bcrypt qw/bcrypt_check/; +use VPNManager::Schema; +use Capture::Tiny qw/capture_stdout/; + +use Mojo::Base 'Mojolicious::Controller', -signatures; +use Path::Tiny; + +sub main($self) { + my $resultset = VPNManager::Schema->Schema->resultset('VPNUser'); + my @users = $resultset->search( {} ); + $self->stash( users => \@users ); + $self->render( template => 'main/index' ); +} + +sub login($self) { + $self->render( template => 'main/login' ); +} + +sub login_post($self) { + my $password = $self->param('password'); + my $username = $self->param('username'); + my ($user) = VPNManager::Schema->Schema->resultset('Admin')->search( + { + username => $username + } + ); + if ( !defined $user ) { + return $self->_invalid_login; + } + if ( !bcrypt_check $password, $user->password ) { + return $self->_invalid_login; + } + $self->session->{user} = $username; + return $self->redirect_to('/'); +} + +sub _invalid_login($self) { + $self->render( text => 'Invalid login', status => 401 ); +} + +sub create_vpn_user($self) { + $self->render( template => 'vpn/create-user' ); +} + +sub create_vpn_user_post($self) { + my $name = $self->param('name'); + my $config = $self->config; + my $starting_ip = $config->{vpnclients}{starting_ip}; + my $resultset = VPNManager::Schema->Schema->resultset('VPNUser'); + my ($last_ip_user) = $resultset->search( + {}, + { + order_by => { -desc => 'ip' }, + rows => 1, + } + ); + my $ip = $starting_ip; + my $there_is_previous_user = 0; + if ( defined $last_ip_user ) { + $ip = $last_ip_user->ip_to_text; + $there_is_previous_user = 1; + } + my $new_user = $resultset->new( + { name => $name, publickey => '', ip => '', is_enabled => 0 } ); + $new_user->ip_from_text($ip); + $new_user->ip( $new_user->ip + 1 ) if $there_is_previous_user; + $ip = $new_user->ip_to_text; + $new_user->insert; + $new_user = $new_user->get_from_storage; + my $id = $new_user->id; + my $url = Mojo::URL->new("/vpn/user/$id/details"); + return $self->redirect_to($url); +} + +sub download_file($self) { + my $id = $self->param('id'); + my $resultset = VPNManager::Schema->Schema->resultset('VPNUser'); + my ($user) = $resultset->search( { id => $id } ); + if ( !defined $user ) { + return $self->render( text => 'No such user', status => 400 ); + } + my $private_key = `wg genkey`; + my $public_key = capture_stdout sub { + open my $fh, '|-', 'wg', 'pubkey'; + print $fh $private_key; + }; + chomp $private_key; + $user->update( { publickey => $public_key } ); + my $config = $self->config; + my $ip = $user->ip_to_text; + my $vpn_file = <<"EOF"; +[Interface] +PrivateKey = $private_key +Address = $ip/32 +DNS = @{[$config->{vpn}{host}]} + +[Peer] +PublicKey = @{[$config->{vpn}{privkey}]} +AllowedIPs = @{[$config->{vpnclients}{allowedips}]} +Endpoint = @{[$config->{vpnclients}{endpoint}]}:@{[$config->{vpnclients}{server_port}]} +EOF + my $filename = $user->name . '-vpn.conf'; + $self->res->headers->add( 'Content-Type', + 'application/x-download;name=' . $filename ); + $self->res->headers->add( 'Content-Disposition', + 'attachment;filename=' . $filename ); + $self->render( data => $vpn_file ); +} + +sub show_user_details($self) { + my $id = $self->param('id'); + my $resultset = VPNManager::Schema->Schema->resultset('VPNUser'); + my ($user) = $resultset->search( { id => $id } ); + if ( !defined $user ) { + return $self->render( text => 'No such user', status => 400 ); + } + $self->stash( details_user => $user ); + return $self->render( template => 'vpn/user-details' ); +} + +sub enable_user($self) { + my $id = $self->param('id'); + my $resultset = VPNManager::Schema->Schema->resultset('VPNUser'); + my ($user) = $resultset->search( { id => $id } ); + if ( !defined $user ) { + return $self->render( text => 'No such user', status => 400 ); + } + if ( $user->publickey eq '' ) { + return $self->render( + text => 'You must first download the vpn file', + status => 400 + ); + } + $user->update( { is_enabled => 1 } ); + return $self->redirect_to('/'); +} + +sub disable_user($self) { + my $id = $self->param('id'); + my $resultset = VPNManager::Schema->Schema->resultset('VPNUser'); + my ($user) = $resultset->search( { id => $id } ); + if ( !defined $user ) { + return $self->render( text => 'No such user', status => 400 ); + } + return $self->render( text => 'This user is protected', status => 400 ) + if $user->is_protected; + $user->update( { is_enabled => 0 } ); + return $self->redirect_to('/'); +} + +#sub save_vpn_settings($self) { +# my $out_dir = path(__FILE__)->parent->parent->parent->parent->child('out'); +# $out_dir->mkpath; +# system 'chmod', '700', $out_dir; +# my $config = $self->config; +# my $vpn_config = <<"EOF"; +#[Interface] +#Address = @{[$config->{vpn}{host}]}/@{[$config->{vpn}{submask}]} +#MTU = @{[$config->{vpn}{mtu}]} +#SaveConfig = false +#ListenPort = @{[$config->{vpnclients}{server_port}]} +#PrivateKey = @{[$config->{vpn}{privkey}]} +#EOF +# my $resultset = VPNManager::Schema->Schema->resultset('VPNUser'); +# my @users = $resultset->search( {} ); +# +# for my $user (@users) { +# next if !$user->is_enabled; +# +# $vpn_config .= <<"EOF"; +# +#[Peer] +#PublicKey = @{[$user->publickey]} +#AllowedIPs = @{[$user->ip_to_text]}/32 +#Endpoint = @{[$config->{vpn}{endpoint}]}:@{[$config->{vpnclients}{server_port}]} +#EOF +# } +# $out_dir->child('wg0.conf')->spew_raw($vpn_config); +# return $self->redirect_to('/'); +#} +1; diff --git a/lib/VPNManager/DB.pm b/lib/VPNManager/DB.pm new file mode 100644 index 0000000..e93174a --- /dev/null +++ b/lib/VPNManager/DB.pm @@ -0,0 +1,116 @@ +package VPNManager::DB; + +use v5.38.2; + +use strict; +use warnings; + +use feature 'signatures'; + +use DBI; +use DBD::SQLite; + +use VPNManager::DB::Migrations; +use Path::Tiny; +use Data::Dumper; + +my $dbh; + +sub reset_dbh { + undef $dbh; +} + +sub connect { + if ( defined $dbh ) { + return $dbh; + } + my $class = shift; + require VPNManager; + my $app = VPNManager->new; + my $config = $app->config; + my $db_path = $class->_db_path; + $dbh = DBI->connect( + 'dbi:SQLite:dbname='.$db_path, + undef, undef, + { + RaiseError => 1, + }, + ); + $class->_migrate($dbh); + return $dbh; +} + +sub _db_path($class) { + my $home = $ENV{HOME}; + my $db_path = ''; + { + $db_path = $home . '/' if $home; + } + $db_path .= '.vpnmanager/db.sqlite'; + path($db_path)->parent->mkpath; + system 'chmod', '-v', '700', path($db_path)->parent; + return $db_path; +} + +sub _migrate { + my $class = shift; + my $dbh = shift; + local $dbh->{RaiseError} = 0; + local $dbh->{PrintError} = 0; + my @migrations = VPNManager::DB::Migrations::MIGRATIONS(); + if ( $class->get_current_migration($dbh) > @migrations ) { + warn "Something happened there, wrong migration number."; + } + if ( $class->get_current_migration($dbh) >= @migrations ) { + say STDERR "Migrations already applied."; + return; + } + $class->_apply_migrations( $dbh, \@migrations ); +} + +sub _apply_migrations { + my $class = shift; + my $dbh = shift; + my $migrations = shift; + for ( + my $i = $class->get_current_migration($dbh) ; + $i < @$migrations ; + $i++ + ) + { + local $dbh->{RaiseError} = 1; + my $current_migration = $migrations->[$i]; + my $migration_number = $i + 1; + $class->_apply_migration( $dbh, $current_migration, $migration_number ); + } +} + +sub _apply_migration { + my $class = shift; + my $dbh = shift; + my $current_migration = shift; + my $migration_number = shift; + { + if (ref $current_migration eq 'CODE') { + $current_migration->($dbh); + next; + } + $dbh->do($current_migration); + } + $dbh->do( <<'EOF', undef, 'current_migration', $migration_number ); +INSERT INTO options +VALUES ($1, $2) +ON CONFLICT (name) DO +UPDATE SET value = $2; +EOF +} + +sub get_current_migration { + my $class = shift; + my $dbh = shift; + my $result = $dbh->selectrow_hashref( <<'EOF', undef, 'current_migration' ); +select value from options where name = ?; +EOF + return int( $result->{value} // 0 ); +} +1; diff --git a/lib/VPNManager/DB/Migrations.pm b/lib/VPNManager/DB/Migrations.pm new file mode 100644 index 0000000..0e42b35 --- /dev/null +++ b/lib/VPNManager/DB/Migrations.pm @@ -0,0 +1,33 @@ +package VPNManager::DB::Migrations; + +use v5.34.1; + +use strict; +use warnings; +use utf8; + +use feature 'signatures'; + +sub MIGRATIONS { + return ( + 'CREATE TABLE options ( + name TEXT PRIMARY KEY, + value TEXT + );', + 'CREATE TABLE vpn_users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + publickey TEXT NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT true, + is_protected INTEGER NOT NULL DEFAULT true, + is_deleted INTEGER NOT NULL DEFAULT false, + ip INTEGER NOT NULL + );', + 'CREATE TABLE admins ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL + );', + ); +} +1; diff --git a/lib/VPNManager/Schema.pm b/lib/VPNManager/Schema.pm new file mode 100644 index 0000000..0ec2582 --- /dev/null +++ b/lib/VPNManager/Schema.pm @@ -0,0 +1,36 @@ +package VPNManager::Schema; + +use v5.36.0; + +use strict; +use warnings; + +use feature 'signatures'; + +use parent 'DBIx::Class::Schema'; + +__PACKAGE__->load_namespaces(); + +my $schema; + +sub Schema ($class) { + if ( !defined $schema ) { + require VPNManager::DB; + VPNManager::DB->connect; + my $db_path = VPNManager::DB->_db_path; + my $user = undef; + my $password = undef; + # Undef is perfectly fine for username and password. + $schema = $class->connect( + 'dbi:SQLite:dbname='.$db_path, $user, $password, + { + } + ); + } + return $schema; +} + +sub reset_schema { + undef $schema; +} +1; diff --git a/lib/VPNManager/Schema/Result/Admin.pm b/lib/VPNManager/Schema/Result/Admin.pm new file mode 100644 index 0000000..d12c2ec --- /dev/null +++ b/lib/VPNManager/Schema/Result/Admin.pm @@ -0,0 +1,29 @@ +package VPNManager::Schema::Result::Admin; + +use v5.38.2; + +use strict; +use warnings; + +use feature 'signatures'; + +use parent 'DBIx::Class::Core'; + +__PACKAGE__->table('admins'); + +__PACKAGE__->add_columns( + id => { + data_type => 'INTEGER', + is_auto_increment => 1, + }, + username => { + data_type => 'TEXT', + is_nullable => 0, + }, + password => { + data_type => 'TEXT', + is_nullable => 0, + }, +); +__PACKAGE__->set_primary_key('id'); +1; diff --git a/lib/VPNManager/Schema/Result/VPNUser.pm b/lib/VPNManager/Schema/Result/VPNUser.pm new file mode 100644 index 0000000..ff63bdb --- /dev/null +++ b/lib/VPNManager/Schema/Result/VPNUser.pm @@ -0,0 +1,62 @@ +package VPNManager::Schema::Result::VPNUser; + +use v5.38.2; + +use strict; +use warnings; + +use feature 'signatures'; + +use parent 'DBIx::Class::Core'; + +__PACKAGE__->table('vpn_users'); + +__PACKAGE__->add_columns( + id => { + data_type => 'INTEGER', + is_auto_increment => 1, + }, + name => { + data_type => 'TEXT', + is_nullable => 0, + }, + publickey => { + data_type => 'TEXT', + is_nullable => 0, + }, + is_enabled => { + data_type => 'INTEGER', + is_nullable => 1, + }, + is_protected => { + data_type => 'INTEGER', + is_nullable => 1, + }, + is_deleted => { + data_type => 'INTEGER', + is_nullable => 1, + }, + ip => { + data_type => 'INTEGER', + is_nullable => 0, + }, +); + +sub ip_to_text($self) { + my @octets; + for my $i (0..3) { + push @octets, ($self->ip >> (abs(3-$i) * 8)) & 0xff; + } + return join '.', @octets; +} + +sub ip_from_text($self, $ip) { + my @octets = split /\./, $ip; + my $raw_ip = 0; + for my $i (0..3) { + $raw_ip |= (($octets[$i] & 0xff) << (abs(3-$i) * 8)); + } + $self->ip($raw_ip); +} +__PACKAGE__->set_primary_key('id'); +1; diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..2ec30f2 --- /dev/null +++ b/public/style.css @@ -0,0 +1,31 @@ +body.login-body { + margin: 0; + padding: 0; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +body.create-user { + margin: 0; + padding: 0; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +body.main { + margin: 0; + padding: 0; + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +form.inline { + display: inline; +} diff --git a/script/create_user.pl b/script/create_user.pl new file mode 100644 index 0000000..939cb2a --- /dev/null +++ b/script/create_user.pl @@ -0,0 +1,29 @@ +#!/usr/bin/env perl + +use v5.38.2; + +use strict; +use warnings; + +use File::Basename qw/dirname/; + +use lib dirname(dirname(__FILE__)).'/lib'; + +use Crypt::Bcrypt qw/bcrypt/; +use Crypt::URandom qw/urandom/; +use VPNManager::Schema; + +my $username = $ARGV[0] or die 'No username passed'; +my $password = $ARGV[1] or die 'No password passed'; + +my $new_salt = urandom(16); +my $encrypted_password = bcrypt $password, '2b', 12, $new_salt; + +VPNManager::Schema->Schema->resultset('Admin')->populate( + [ + { + username => $username, + password => $encrypted_password, + } + ] +); diff --git a/script/get_wg_config.pl b/script/get_wg_config.pl new file mode 100644 index 0000000..870f7f4 --- /dev/null +++ b/script/get_wg_config.pl @@ -0,0 +1,39 @@ +#!/usr/bin/env perl +use v5.38.2; + +use strict; +use warnings; + +use Moo; +use File::Basename qw/dirname/; +use lib dirname(dirname(__FILE__)).'/lib'; +use VPNManager::Schema; + +sub get_vpn_settings($self) { + require VPNManager; + my $config = VPNManager->new->config; + my $vpn_config = <<"EOF"; +[Interface] +Address = @{[$config->{vpn}{host}]}/@{[$config->{vpn}{submask}]} +MTU = @{[$config->{vpn}{mtu}]} +SaveConfig = false +ListenPort = @{[$config->{vpnclients}{server_port}]} +PrivateKey = @{[$config->{vpn}{privkey}]} +EOF + my $resultset = VPNManager::Schema->Schema->resultset('VPNUser'); + my @users = $resultset->search( {} ); + + for my $user (@users) { + next if !$user->is_enabled; + + $vpn_config .= <<"EOF"; + +[Peer] +PublicKey = @{[$user->publickey]} +AllowedIPs = @{[$user->ip_to_text]}/32 +Endpoint = @{[$config->{vpn}{endpoint}]}:@{[$config->{vpnclients}{server_port}]} +EOF + } + say $vpn_config; +} +__PACKAGE__->new->get_vpn_settings; diff --git a/script/installer.pl b/script/installer.pl new file mode 100644 index 0000000..c80a34d --- /dev/null +++ b/script/installer.pl @@ -0,0 +1,42 @@ +#!/usr/bin/env perl + +use v5.38.2; + +use strict; +use warnings; +use File::Basename qw/dirname/; +use Cwd 'abs_path'; + +if ($> != 0) { + die 'You must be root.'; +} + +while (1) { + eval { + install_if_new(); + sleep 15; + }; + if ($@) { + warn $@; + } +} + +sub install_if_new { + my $script_get_wg_config = abs_path(dirname(__FILE__).'/get_wg_config.pl'); + my $user = 'vpnmanager'; + open my $fh, '-|', 'sudo', '-i', '-u', $user, 'perl', $script_get_wg_config; + my $contents = join '', <$fh>; + my $output_file = '/etc/wireguard/wg0.conf'; + my $output_exists; + open $fh, '<', $output_file and ($output_exists = 1); + my $contents_output_file = ''; + $contents_output_file = join '', <$fh> if $output_exists; + if ($contents ne $contents_output_file) { + say 'Writting new file'; + open $fh, '>', $output_file; + print $fh $contents; + system 'systemctl', 'restart', 'vpnmanager'; + return; + } + say 'Files equal'; +} diff --git a/script/vpnmanager b/script/vpnmanager new file mode 100755 index 0000000..866194b --- /dev/null +++ b/script/vpnmanager @@ -0,0 +1,11 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use Mojo::File qw(curfile); +use lib curfile->dirname->sibling('lib')->to_string; +use Mojolicious::Commands; + +# Start command line interface for application +Mojolicious::Commands->start_app('VPNManager'); diff --git a/templates/layouts/default.html.ep b/templates/layouts/default.html.ep new file mode 100644 index 0000000..599c556 --- /dev/null +++ b/templates/layouts/default.html.ep @@ -0,0 +1,5 @@ + + + <%= title %> + <%= content %> + diff --git a/templates/main/index.html.ep b/templates/main/index.html.ep new file mode 100644 index 0000000..5a24a10 --- /dev/null +++ b/templates/main/index.html.ep @@ -0,0 +1,22 @@ +% my $users = stash 'users'; + + + + + +

Create new vpn user.

+% if (defined $users && @$users) { + +
+ +
+ + diff --git a/templates/main/login.html.ep b/templates/main/login.html.ep new file mode 100644 index 0000000..5d45373 --- /dev/null +++ b/templates/main/login.html.ep @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/templates/vpn/create-user.html.ep b/templates/vpn/create-user.html.ep new file mode 100644 index 0000000..b0ba3e3 --- /dev/null +++ b/templates/vpn/create-user.html.ep @@ -0,0 +1,14 @@ + + + + + +
+
+ + +
+ +
+ + diff --git a/templates/vpn/download-file.html.ep b/templates/vpn/download-file.html.ep new file mode 100644 index 0000000..05aedf1 --- /dev/null +++ b/templates/vpn/download-file.html.ep @@ -0,0 +1,10 @@ +% use MIME::Base64 qw/encode_base64/; +% my $file = stash 'vpn_file'; + + + + + + Dowload vpn file + + diff --git a/templates/vpn/user-details.html.ep b/templates/vpn/user-details.html.ep new file mode 100644 index 0000000..bc165de --- /dev/null +++ b/templates/vpn/user-details.html.ep @@ -0,0 +1,16 @@ +% use MIME::Base64 qw/encode_base64/; +% my $user = stash 'details_user'; + + + + + +

VPN Account

+

Name: <%=$user->name%>

+

Internal IP Address: <%=$user->ip_to_text%>

+
+ +
+

Volver al menu principal

+ + diff --git a/v_p_n_manager.example.yml b/v_p_n_manager.example.yml new file mode 100644 index 0000000..555b9e3 --- /dev/null +++ b/v_p_n_manager.example.yml @@ -0,0 +1,16 @@ +--- +secrets: + - generated_secret +vpn: + host: '192.168.2.1' + submask: 24 + privkey: 'your_private_key' + listenport: 51820 + mtu: 1420 + endpoint: 'your_endpoint' +vpnclients: + submask: 32 + allowedips: '192.168.2.0/24' + endpoint: 'your_endpoint' + server_port: 51820 + starting_ip: '192.168.2.2'