Replaced shell with playbook CLI, structured js app

This commit is contained in:
Ivan Gromov 2019-11-22 03:03:45 +05:00
parent b9bffa32aa
commit 4867a034f8
6 changed files with 486 additions and 344 deletions

View file

@ -1,298 +0,0 @@
<!DOCTYPE html>
<html class="h-100">
<head>
<title>Algo webapp</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/xterm/3.14.5/xterm.min.css"
integrity="sha256-uTIrmf95e6IHlacC0wpDaPS58eWF314UC7OgdrD6AdU=" crossorigin="anonymous"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.10/vue.min.js"
integrity="sha256-chlNFSVx3TdcQ2Xlw7SvnbLAavAQLO0Y/LBiWX04viY=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xterm/3.14.5/xterm.min.js"
integrity="sha256-tDeULIXIGkXbz7dkZ0qcQajBIS22qS8jQ6URaeMoVJs=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.bundle.js"
integrity="sha256-pVreZ67fRaATygHF6T+gQtF1NI700W9kzeAivu6au9U=" crossorigin="anonymous"></script>
</head>
<body class="h-100">
<div class="d-flex flex-column h-100" id="app">
<div class="flex-shrink-0" v-if="formMode">
<div class="container">
<h1 class="mb-5 text-center">Algo VPN Setup</h1>
<div class="row">
<div class="col-md-4 order-md-2 mb-4">
<h2>Users</h2>
<section class="my-3">
<h4>Set up user list</h4>
<ul class="list-group">
<li class="list-group-item"
v-for="(user, index) in config.users"
:key="user"
>
{{ user }}
<button type="button" class="btn btn-secondary btn-sm float-right"
@click="removeUser(index)">
Remove
</button>
</li>
</ul>
<div class="my-3 form-group">
<label for="id_new_user">Add new user</label>
<div class="input-group">
<input type="text" id="id_new_user" class="form-control" placeholder="username"
v-model="newUser">
<div class="input-group-append">
<button @click="addUser" class="btn btn-outline-primary" type="button"
id="button-addon2">
Add
</button>
</div>
</div>
</div>
</section>
<div>
<button @click="save" v-bind:disabled="loading" class="btn btn-secondary" type="button">Save
</button>
<span v-if="saveConfigMessage"
v-bind:class="{ 'text-success': ok, 'text-danged': !ok }">{{saveConfigMessage}}</span>
</div>
</div>
<div class="col-md-8 order-md-1">
<h2>VPN Options</h2>
<section class="my-3">
<div class="form-group">
<label>Name the vpn server</label>
<input type="text" class="form-control" placeholder="server name"
v-model="extra_args.server_name"/>
</div>
<label>MacOS/iOS IPsec clients to enable Connect On Demand:</label>
<div class="form-check">
<label title="MacOS/iOS IPsec clients to enable Connect On Demand when connected to cellular
networks?">
<input class="form-check-input" type="checkbox" name="ondemand_cellular"
v-model="extra_args.ondemand_cellular">
when connected to cellular networks
</label>
</div>
<div class="form-check">
<label title="MacOS/iOS IPsec clients to enable Connect On Demand when connected to Wi-Fi?">
<input class="form-check-input" type="checkbox" name="ondemand_wifi"
v-model="extra_args.ondemand_wifi">
when connected to WiFi
</label>
</div>
<div class="form-group">
<label for="id_ondemand_wifi_exclude">Trusted Wi-Fi networks</label>
<input type="text" class="form-control" id="id_ondemand_wifi_exclude"
name="ondemand_wifi_exclude"
placeholder="HomeNet,OfficeWifi,AlgoWiFi"
v-model="extra_args.ondemand_wifi_exclude"/>
<small class="form-text text-muted">
List the names of any trusted Wi-Fi networks where
macOS/iOS
IPsec clients should not use "Connect On Demand"
(e.g., your home network. Comma-separated value, e.g.,
HomeNet,OfficeWifi,AlgoWiFi)
</small>
</div>
<label>Retain the PKI</label>
<div class="form-check">
<label>
<input class="form-check-input" type="checkbox" name="store_pki"
v-model="extra_args.store_pki">
Do you want to retain the keys (PKI)?
<small class="form-text text-muted">required to add users in the future, but less
secure</small>
</label>
</div>
<label>DNS adblocking</label>
<div class="form-check">
<label>
<input class="form-check-input" type="checkbox" name="dns_adblocking"
v-model="extra_args.dns_adblocking">
Enable DNS ad blocking on this VPN server
</label>
</div>
<label>SSH tunneling</label>
<div class="form-check">
<label>
<input class="form-check-input" type="checkbox" name="ssh_tunneling"
v-model="extra_args.ssh_tunneling">
Each user will have their own account for SSH tunneling
</label>
</div>
</section>
</div>
</div>
<hr class="my-3">
<section class="my-3">
<h2>Select cloud provider</h2>
<div class="form-check">
<label>
<input class="form-check-input" type="radio" name="provider" value="digitalocean"
v-model="extra_args.provider">
DigitalOcean
</label>
</div>
<div class="form-check">
<label>
<input class="form-check-input" type="radio" name="provider" value="lightsail"
v-model="extra_args.provider">
Amazon Lightsail
</label>
</div>
</section>
<section class="my-3" v-if="extra_args.provider === 'digitalocean'">
<h4>Digital Ocean Options</h4>
<div class="form-group">
<label for="id_do_token">Enter your API token. The token must have read and write permissions
(https://cloud.digitalocean.com/settings/api/tokens):</label>
<input type="text" class="form-control" id="id_do_token" name="do_token"
v-model="extra_args.do_token"
@blur="load_do_regions"/>
</div>
<div class="form-group">
<label for="id_region">What region should the server be located in?</label>
<select name="region" id="id_region" class="form-control" v-model="extra_args.region">
<option value="" disabled>Select region</option>
<option
v-for="(region, index) in do_regions"
v-bind:value="region.slug">
{{region.name}}
</option>
</select>
</div>
</section>
</div>
</div>
<div v-if="consoleMode" id="terminal">
</div>
<footer class="footer mt-auto py-3" v-if="formMode">
<div class="container text-center">
<button @click="start" v-bind:disabled="loading" class="btn btn-primary btn-lg" type="button">Install
</button>
</div>
</footer>
</div>
<script>
var ws = new WebSocket("ws://127.0.0.1:8080/ws");
window.ws = ws;
ws.onopen = function (event) {
init();
}
function init() {
var app = new Vue({
el: '#app',
data: {
ok: false,
config: {},
do_regions: {},
extra_args: {
server_name: 'algo',
ondemand_cellular: false,
ondemand_wifi: false,
dns_adblocking: false,
ssh_tunneling: false,
store_pki: false,
ondemand_wifi_exclude: ''
},
loading: false,
newUser: '',
saveConfigMessage: '',
formMode: true,
consoleMode: false
},
methods: {
addUser: function () {
this.config.users.push(this.newUser);
this.newUser = '';
},
removeUser: function(index) {
this.config.users.splice(index, 1);
},
save: function() {
this.loading = true;
fetch('/config', {
method: 'POST',
body: JSON.stringify(this.config),
headers: {
'Content-Type': 'application/json'
}
}).then(r => r.json()).then(result => {
if (result.ok) {
this.ok = true;
this.saveConfigMessage = 'Saved!';
setTimeout(() => {
this.saveConfigMessage = '';
}, 1000);
} else {
this.ok = false;
this.saveConfigMessage = 'Not Saved!';
setTimeout(() => {
this.saveConfigMessage = '';
}, 1000);
}
}).finally(() => {
this.loading = false;
});
},
load: function() {
this.loading = true;
fetch('/config').then(r => r.json()).then(config => {
this.config = config;
}).finally(() => {
this.loading = false;
});
},
start: function() {
var args = '';
for (arg in this.extra_args) {
args += `${arg}=${this.extra_args[arg]} `;
}
ws.send(`ansible-playbook main.yml --extra-vars "${args}"`);
this.formMode = false;
var term = new Terminal();
term.open(document.getElementById('terminal'));
ws.onmessage = function(event) {
term.write(event.data + '\r\n');
if (event.data.length > term.cols) {
term.resize(event.data.length, term.rows);
}
};
window.term = term;
this.consoleMode = true;
},
load_do_regions: function() {
if (this.extra_args.provider === 'digitalocean' && this.extra_args.do_token) {
this.loading = true;
fetch('/do/regions', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({token: this.extra_args.do_token})
}).then(r => r.json()).then(r => {
this.do_regions = r.regions;
}).finally(() => {
this.loading = false;
});
}
}
}
});
app.load();
window.app = app;
window.onclose = function() {
if (ws.readyState === ws.OPEN) {
ws.send('close');
}
}
}
</script>
</body>
</html>

1
app/requirements.txt Normal file
View file

@ -0,0 +1 @@
aiohttp==3.6.2

View file

@ -1,48 +1,80 @@
import asyncio
import signal
import sys
import mimetypes
import aiohttp
import yaml
from os.path import join, dirname
from aiohttp import web
from ansible.cli.playbook import PlaybookCLI
from time import sleep
import concurrent.futures
routes = web.RouteTableDef()
PROJECT_ROOT = dirname(dirname(__file__))
jobs = []
pool = None
task_future = None
task_program = ''
def run_playbook(data={}):
global task_program
extra_vars = ' '.join(['{0}={1}'.format(key, data[key]) for key in data.keys()])
task_program = ['ansible-playbook', 'main.yml', '--extra-vars', extra_vars]
cli = PlaybookCLI(task_program).run()
return cli
@routes.get('/static/{path}')
async def handle_static(request):
filepath = request.match_info['path']
mimetype = mimetypes.guess_type(filepath)
try:
with open(join(dirname(__file__), 'static', *filepath.split('/')), 'r') as f:
return web.Response(body=f.read(), content_type=mimetype[0])
except FileNotFoundError:
return web.Response(status=404)
@routes.get('/')
async def handle_index(_):
with open(join(PROJECT_ROOT, 'app', 'index.html'), 'r') as f:
with open(join(PROJECT_ROOT, 'app', 'static', 'index.html'), 'r') as f:
return web.Response(body=f.read(), content_type='text/html')
async def websocket_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
@routes.get('/playbook')
async def playbook_get_handler(request):
if not task_future:
return web.json_response({'status': None})
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
if msg.data == 'close':
await ws.close()
else:
p = await asyncio.create_subprocess_shell(
msg.data,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
jobs.append(p)
while True:
line = await p.stdout.readline()
if not line:
break
else:
await ws.send_str(line.decode('ascii').rstrip())
if task_future.done():
return web.json_response({'status': 'done', 'program': task_program, 'result': task_future.result()})
elif task_future.cancelled():
return web.json_response({'status': 'cancelled', 'program': task_program})
else:
return web.json_response({'status': 'running', 'program': task_program})
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
print('websocket connection closed')
return ws
@routes.post('/playbook')
async def playbook_post_handler(request):
global task_future
global pool
data = await request.json()
loop = asyncio.get_running_loop()
pool = concurrent.futures.ThreadPoolExecutor()
task_future = loop.run_in_executor(pool, run_playbook, data)
return web.json_response({'ok': True})
@routes.delete('/playbook')
async def playbook_delete_handler(request):
global task_future
if not task_future:
return web.json_response({'ok': False})
cancelled = task_future.cancel()
pool.shutdown(wait=False)
task_future = None
return web.json_response({'ok': cancelled})
@routes.get('/config')
@ -81,21 +113,7 @@ async def get_do_regions(request):
json_body = await r.json()
return web.json_response(json_body)
app = web.Application()
app.router.add_get('/ws', websocket_handler)
app.router.add_get('/', handle_index)
app.router.add_routes(routes)
web.run_app(app)
def signal_handler(sig, frame):
print('Closing child processes')
for p in jobs:
try:
p.terminate()
except:
pass
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
print('Press Ctrl+C to stop')
signal.pause()

202
app/static/app.js Normal file
View file

@ -0,0 +1,202 @@
new Vue({
el: '#users_app',
data: {
config: {},
loading: false,
new_user: '',
save_config_message: ''
},
created: function() {
this.load_config();
},
methods: {
add_user: function() {
this.config.users.push(this.new_user);
this.new_user = '';
},
remove_user: function(index) {
this.config.users.splice(index, 1);
},
save_config: function() {
if (this.loading) return;
this.loading = true;
fetch('/config', {
method: 'POST',
body: JSON.stringify(this.config),
headers: {
'Content-Type': 'application/json'
}
})
.then(r => r.json())
.then(result => {
if (result.ok) {
this.ok = true;
this.save_config_message = 'Saved!';
setTimeout(() => {
this.save_config_message = '';
}, 1000);
} else {
this.ok = false;
this.save_config_message = 'Not Saved!';
setTimeout(() => {
this.save_config_message = '';
}, 1000);
}
})
.finally(() => {
this.loading = false;
});
},
load_config: function() {
this.loading = true;
fetch('/config')
.then(r => r.json())
.then(config => {
this.config = config;
})
.finally(() => {
this.loading = false;
});
}
}
});
var vpn_options_extra_args = {
server_name: 'algo',
ondemand_cellular: false,
ondemand_wifi: false,
dns_adblocking: false,
ssh_tunneling: false,
store_pki: false,
ondemand_wifi_exclude: ''
};
new Vue({
el: '#options_app',
data: {
extra_args: vpn_options_extra_args
}
});
var provider_extra_args = {
provider: null
};
new Vue({
el: '#provider_app',
data: {
loading: false,
do_regions: [],
extra_args: provider_extra_args,
providers_map: [
{ name: 'DigitalOcean', alias: 'digitalocean' },
{ name: 'Amazon Lightsail', alias: 'lightsail' },
{ name: 'Amazon EC2', alias: 'ec2' },
{ name: 'Microsoft Azure', alias: 'azure' },
{ name: 'Google Compute Engine', alias: 'gce' },
{ name: 'Hetzner Cloud', alias: 'hetzner' },
{ name: 'Vultr', alias: 'vultr' },
{ name: 'Scaleway', alias: 'scaleway' },
{ name: 'OpenStack (DreamCompute optimised)', alias: 'openstack' },
{ name: 'CloudStack (Exoscale optimised)', alias: 'cloudstack' },
{
name: 'Install to existing Ubuntu 18.04 or 19.04 server (Advanced)',
alias: 'local'
}
]
},
methods: {
set_provider(provider) {
this.extra_args.provider = provider;
},
load_do_regions: function() {
if (
this.extra_args.provider === 'digitalocean' &&
this.extra_args.do_token
) {
this.loading = true;
fetch('/do/regions', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token: this.extra_args.do_token })
})
.then(r => r.json())
.then(r => {
this.do_regions = r.regions;
})
.finally(() => {
this.loading = false;
});
}
}
}
});
new Vue({
el: '#status_app',
data: {
status: null,
program: null,
result: null,
error: null,
// shared data, do not write there
vpn_options_extra_args,
provider_extra_args,
},
created() {
this.loop = setInterval(this.get_status, 1000);
},
computed: {
extra_args() {
return Object.assign({}, this.vpn_options_extra_args, this.provider_extra_args);
},
cli_preview() {
var args = '';
for (arg in this.extra_args) {
args += `${arg}=${this.extra_args[arg]} `;
}
return `ansible-playbook main.yml --extra-vars ${args}`;
},
show_backdrop() {
return this.status === 'running';
}
},
watch: {
status: function () {
if (this.status === 'done') {
clearInterval(this.loop);
}
}
},
methods: {
run() {
fetch('/playbook', {
method: 'POST',
body: JSON.stringify(this.extra_args),
headers: {
'Content-Type': 'application/json'
}
});
},
stop() {
fetch('/playbook', {
method: 'DELETE'
});
},
get_status() {
fetch('/playbook')
.then(r => r.json())
.then(status => {
this.status = status.status;
this.program = status.program;
this.result = status.result;
})
.catch(err => {
alert('Server error');
clearInterval(this.loop);
});
}
}
});

222
app/static/index.html Normal file
View file

@ -0,0 +1,222 @@
<!DOCTYPE html>
<html class="h-100">
<head>
<title>Algo VPN</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/xterm/3.14.5/xterm.min.css"
integrity="sha256-uTIrmf95e6IHlacC0wpDaPS58eWF314UC7OgdrD6AdU=" crossorigin="anonymous"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.10/vue.min.js"
integrity="sha256-chlNFSVx3TdcQ2Xlw7SvnbLAavAQLO0Y/LBiWX04viY=" crossorigin="anonymous"></script>
<style>
.console {
background: black;
color: white;
padding: 4px;
border-radius: 2px;
white-space: pre-line;
padding-left: 2em;
position: relative;
}
.console::before {
content: "$";
position: absolute;
left: 1em;
}
.backdrop {
position: fixed;
background: white;
opacity: 0.6;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
pointer-events: none;
}
.footer .container {
position: relative;
z-index: 101;
}
</style>
</head>
<body class="d-flex flex-column h-100">
<div style="overflow: auto">
<div class="container">
<h1 class="mb-5 text-center">Algo VPN Setup</h1>
<div class="row">
<div class="col-md-4 order-md-2 mb-4" id="users_app">
<h2>Users</h2>
<section class="my-3">
<h4>Set up user list</h4>
<ul class="list-group">
<li class="list-group-item"
v-for="(user, index) in config.users"
:key="user"
>
{{ user }}
<button type="button" class="btn btn-secondary btn-sm float-right"
@click="remove_user(index)">
Remove
</button>
</li>
</ul>
<div class="my-3 form-group">
<label for="id_new_user">Add new user</label>
<div class="input-group">
<input type="text" id="id_new_user" class="form-control" placeholder="username"
v-model="new_user">
<div class="input-group-append">
<button @click="add_user" class="btn btn-outline-primary" type="button"
id="button-addon2">
Add
</button>
</div>
</div>
</div>
</section>
<div>
<button @click="save_config" v-bind:disabled="loading" class="btn btn-secondary" type="button">Save
</button>
<span v-if="save_config_message"
v-bind:class="{ 'text-success': ok, 'text-danged': !ok }">{{save_config_message}}</span>
</div>
</div>
<div class="col-md-8 order-md-1" id="options_app">
<h2>VPN Options</h2>
<section class="my-3">
<div class="form-group">
<label>Name the vpn server</label>
<input type="text" class="form-control" placeholder="server name"
v-model="extra_args.server_name"/>
</div>
<label>MacOS/iOS IPsec clients to enable Connect On Demand:</label>
<div class="form-check">
<label title="MacOS/iOS IPsec clients to enable Connect On Demand when connected to cellular
networks?">
<input class="form-check-input" type="checkbox" name="ondemand_cellular"
v-model="extra_args.ondemand_cellular">
when connected to cellular networks
</label>
</div>
<div class="form-check">
<label title="MacOS/iOS IPsec clients to enable Connect On Demand when connected to Wi-Fi?">
<input class="form-check-input" type="checkbox" name="ondemand_wifi"
v-model="extra_args.ondemand_wifi">
when connected to WiFi
</label>
</div>
<div class="form-group">
<label for="id_ondemand_wifi_exclude">Trusted Wi-Fi networks</label>
<input type="text" class="form-control" id="id_ondemand_wifi_exclude"
name="ondemand_wifi_exclude"
placeholder="HomeNet,OfficeWifi,AlgoWiFi"
v-model="extra_args.ondemand_wifi_exclude"/>
<small class="form-text text-muted">
List the names of any trusted Wi-Fi networks where
macOS/iOS
IPsec clients should not use "Connect On Demand"
(e.g., your home network. Comma-separated value, e.g.,
HomeNet,OfficeWifi,AlgoWiFi)
</small>
</div>
<label>Retain the PKI</label>
<div class="form-check">
<label>
<input class="form-check-input" type="checkbox" name="store_pki"
v-model="extra_args.store_pki">
Do you want to retain the keys (PKI)?
<small class="form-text text-muted">required to add users in the future, but less
secure</small>
</label>
</div>
<label>DNS adblocking</label>
<div class="form-check">
<label>
<input class="form-check-input" type="checkbox" name="dns_adblocking"
v-model="extra_args.dns_adblocking">
Enable DNS ad blocking on this VPN server
</label>
</div>
<label>SSH tunneling</label>
<div class="form-check">
<label>
<input class="form-check-input" type="checkbox" name="ssh_tunneling"
v-model="extra_args.ssh_tunneling">
Each user will have their own account for SSH tunneling
</label>
</div>
</section>
</div>
</div>
<hr class="my-3">
<section class="my-3" id="provider_app">
<h2>Select cloud provider</h2>
<div class="row">
<div class="col-4">
<ul class="nav flex-column nav-pills">
<li class="nav-item"
v-for="provider in providers_map">
<a class="nav-link" href="#"
v-bind:class="{ active: provider.alias === extra_args.provider }"
@click="set_provider(provider.alias)">{{provider.name}}</a>
</li>
</ul>
</div>
<div class="col-8">
<div class="my-3" v-if="extra_args.provider === 'digitalocean'">
<h4>Digital Ocean Options</h4>
<div class="form-group">
<label for="id_do_token">Enter your API token. The token must have read and write permissions
(https://cloud.digitalocean.com/settings/api/tokens):</label>
<input type="text" class="form-control" id="id_do_token" name="do_token"
v-model="extra_args.do_token"
@blur="load_do_regions"/>
</div>
<div class="form-group">
<label for="id_region">What region should the server be located in?</label>
<select name="region" id="id_region" class="form-control" v-model="extra_args.region">
<option value="" disabled>Select region</option>
<option
v-for="(region, index) in do_regions"
v-bind:value="region.slug">
{{region.name}}
</option>
</select>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<footer class="footer mt-auto py-3" id="status_app">
<div class="backdrop d-flex flex-column align-items-center justify-content-center" v-if="show_backdrop">
<span class="spinner-border" role="status" aria-hidden="true"></span>
</div>
<div class="container">
<div v-if="!status || status === 'cancelled'">
<pre class="console">{{cli_preview}}</pre>
<button @click="run" class="btn btn-primary" type="button">Install</button>
</div>
<div v-if="status === 'running'">
<pre class="console">{{program.join(' ')}}</pre>
<button class="btn btn-danger" type="button" @click="stop">Stop</button>
<button class="btn btn-primary" type="button" disabled>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Running...
</button>
</div>
<div v-if="status === 'done'">
<pre class="console">{{program.join(' ')}}</pre>
<div class="text-success">Done!</div>
</div>
</div>
</footer>
<script src="./static/app.js"></script>
</body>
</html>

View file

@ -1,6 +1,3 @@
ansible==2.9.20
jinja2==2.8
netaddr
PyYAML==5.1.2
aiodns==2.0.0
aiohttp==3.6.2