Initial commit.

This commit is contained in:
sergiotarxz 2024-07-16 23:49:24 +02:00
commit 93b5bf300a
21 changed files with 828 additions and 0 deletions

28
Build.PL Executable file
View File

@ -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 <contact@owlcode.tech>',
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;

70
lib/VPNManager.pm Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

116
lib/VPNManager/DB.pm Normal file
View File

@ -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;

View File

@ -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;

36
lib/VPNManager/Schema.pm Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

31
public/style.css Normal file
View File

@ -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;
}

29
script/create_user.pl Normal file
View File

@ -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,
}
]
);

39
script/get_wg_config.pl Normal file
View File

@ -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;

42
script/installer.pl Normal file
View File

@ -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';
}

11
script/vpnmanager Executable file
View File

@ -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');

View File

@ -0,0 +1,5 @@
<!DOCTYPE html>
<html>
<head><title><%= title %></title></head>
<body><%= content %></body>
</html>

View File

@ -0,0 +1,22 @@
% my $users = stash 'users';
<html>
<head>
<link rel="stylesheet" href="/style.css"/>
</head>
<body class="main">
<p><a href="/vpn/create-user">Create new vpn user</a>.</p>
% if (defined $users && @$users) {
<ul>
% for my $user (@$users) {
<li>Id: <b><%=$user->id%></b> Name: <b><%=$user->name%></b> Internal IP Address: <b><%=$user->ip_to_text%></b> <a href="/vpn/user/<%=$user->id%>/details">View details</a>
<form class="inline" method="post" action="/vpn/user/<%=$user->id%>/<%=$user->is_enabled ? 'disable' : 'enable'%>">
<input type="submit" value="<%=$user->is_enabled? 'Disable' : 'Enable'%>"/>
</form></li>
% }
% }
</ul>
<form action="/vpn/save" method="post">
<input type="submit" value="Save VPN Settings"/>
</form>
</body>
</html>

View File

@ -0,0 +1,20 @@
<html>
<head>
<link rel="stylesheet" href="/style.css"/>
</head>
<body class="login-body">
<form class="login-form" action="/login" method="post">
<div class="login-field">
<label for="username">Username</label>
<input name="username" type="text"/>
</div>
<div class="login-field">
<label for="password">Password</label>
<input name="password" type="password"/>
</div>
<div class="login-field">
<input type="submit" value="Submit"/>
</div>
</form>
</body>
</html>

View File

@ -0,0 +1,14 @@
<html>
<head>
<link rel="stylesheet" href="/style.css"/>
</head>
<body class="create-user">
<form action="/vpn/create-user" method="post">
<div>
<label for="name">Name</label>
<input name="name"/>
</div>
<input type="submit" value="Submit"/>
</form>
</body>
</html>

View File

@ -0,0 +1,10 @@
% use MIME::Base64 qw/encode_base64/;
% my $file = stash 'vpn_file';
<html>
<head>
<link rel="stylesheet" href="/style.css"/>
</head>
<body>
<a href="/vpn/user/<%=$user->id%>/download" download="wg0.conf">Dowload vpn file</a>
</body>
</html>

View File

@ -0,0 +1,16 @@
% use MIME::Base64 qw/encode_base64/;
% my $user = stash 'details_user';
<html>
<head>
<link rel="stylesheet" href="/style.css"/>
</head>
<body>
<h1>VPN Account</h1>
<h2>Name: <%=$user->name%></h2>
<h2>Internal IP Address: <%=$user->ip_to_text%></h2>
<form action="/vpn/user/<%=$user->id%>/download" target="_blank" method="post">
<input type="submit" value="Download new VPN file"/>
</form>
<p><a href="/">Volver al menu principal</a></p>
</body>
</html>

16
v_p_n_manager.example.yml Normal file
View File

@ -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'