This commit is contained in:
2023-09-25 01:19:50 +03:00
commit 39e480b655
15 changed files with 786 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
config.yaml

3
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

10
.idea/OutlineFleet.iml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (OutlineFleet)" project-jdk-type="Python SDK" />
<component name="PyCharmProfessionalAdvertiser">
<option name="shown" value="true" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/OutlineFleet.iml" filepath="$PROJECT_DIR$/.idea/OutlineFleet.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

70
lib.py Normal file
View File

@ -0,0 +1,70 @@
import logging
from typing import TypedDict, List
from outline_vpn.outline_vpn import OutlineKey, OutlineVPN
import yaml
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%d-%m-%Y %H:%M:%S')
log = logging.getLogger('OutlineFleet.lib')
class ServerDict(TypedDict):
name: str
url: str
cert: str
comment: str
server_id: str
metrics_enabled: str
created_timestamp_ms: int
version: str
port_for_new_access_keys: int
hostname_for_access_keys: str
keys: List[OutlineKey]
class Server:
def __init__(self,
url: str,
cert: str,
comment: str,
):
self.client = OutlineVPN(api_url=url, cert_sha256=cert)
self.data: ServerDict = {
'name': self.client.get_server_information()["name"],
'url': url,
'cert': cert,
'comment': comment,
'server_id': self.client.get_server_information()["serverId"],
'metrics_enabled': self.client.get_server_information()["metricsEnabled"],
'created_timestamp_ms': self.client.get_server_information()["createdTimestampMs"],
'version': self.client.get_server_information()["version"],
'port_for_new_access_keys': self.client.get_server_information()["portForNewAccessKeys"],
'hostname_for_access_keys': self.client.get_server_information()["hostnameForAccessKeys"],
'keys': self.client.get_keys()
}
def info(self) -> ServerDict:
return self.data
def apply_config(self, config):
if config.get("name"):
self.client.set_server_name(config.get("name"))
log.info("Changed %s name to '%s'", self.data["server_id"], config.get("name"))
if config.get("metrics"):
self.client.set_metrics_status(True if config.get("metrics") == 'True' else False)
log.info("Changed %s metrics status to '%s'", self.data["server_id"], config.get("metrics"))
if config.get("port_for_new_access_keys"):
self.client.set_port_new_for_access_keys(int(config.get("port_for_new_access_keys")))
log.info("Changed %s port_for_new_access_keys to '%s'", self.data["server_id"], config.get("port_for_new_access_keys"))
if config.get("hostname_for_access_keys"):
self.client.set_hostname(config.get("hostname_for_access_keys"))
log.info("Changed %s hostname_for_access_keys to '%s'", self.data["server_id"], config.get("hostname_for_access_keys"))
if config.get("comment"):
with open("config.yaml", "r") as file:
config_file = yaml.safe_load(file) or {}
config_file["servers"][self.data['server_id']]['comment'] = config.get("comment")
with open("config.yaml", "w") as file:
yaml.safe_dump(config_file, file)
log.info("Changed %s comment to '%s'", self.data["server_id"], config.get("comment"))

91
main.py Normal file
View File

@ -0,0 +1,91 @@
from outline_vpn.outline_vpn import OutlineVPN
import yaml
import logging
from datetime import datetime
from flask import Flask, render_template, request, url_for, redirect
from lib import Server
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%d-%m-%Y %H:%M:%S')
log = logging.getLogger('OutlineFleet')
SERVERS = list()
app = Flask(__name__)
def format_timestamp(ts):
return datetime.fromtimestamp(ts//1000).strftime('%Y-%m-%d %H:%M:%S')
def update_state():
global SERVERS
SERVERS = list()
config = dict()
try:
with open("config.yaml", "r") as file:
config = yaml.safe_load(file)
except:
with open("config.yaml", "w"):
pass
if config:
servers = config.get('servers', None)
for server_id, config in servers.items():
server = Server(url=config["url"], cert=config["cert"], comment=config["comment"])
SERVERS.append(server)
log.info("Server found: %s", server.info()["name"])
@app.route('/', methods=['GET', 'POST'])
def index():
if request.method == 'GET':
return render_template(
'index.html',
SERVERS=SERVERS,
nt=request.args.get('nt'),
nl=request.args.get('nl'),
selected_server=request.args.get('selected_server'),
format_timestamp=format_timestamp)
else:
server = request.form['server_id']
server = next((item for item in SERVERS if item.info()["server_id"] == server), None)
server.apply_config(request.form)
update_state()
return redirect(url_for('index', nt="Updated Outline VPN Server", selected_server=request.args.get('selected_server')))
@app.route('/add_server', methods=['GET', 'POST'])
def add_server():
if request.method == 'GET':
return render_template('add_server.html')
else:
with open("config.yaml", "r") as file:
config = yaml.safe_load(file) or {}
servers = config.get('servers', dict())
try:
new_server = Server(url=request.form['url'], cert=request.form['cert'], comment=request.form['comment'])
except:
return redirect(url_for('index', nt="Couldn't access Outline VPN Server", nl="error"))
servers[new_server.data["server_id"]] = {
'name': new_server.data["name"],
'url': new_server.data["url"],
'comment': new_server.data["comment"],
'cert': request.form['cert']
}
config["servers"] = servers
with open("config.yaml", "w") as file:
yaml.safe_dump(config, file)
update_state()
return redirect(url_for('index', nt="Added Outline VPN Server"))
if __name__ == '__main__':
update_state()
app.run()

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
outline-vpn-api
PyYAML
Flask

355
static/layout.css Normal file
View File

@ -0,0 +1,355 @@
/*
* -- BASE STYLES --
* Most of these are inherited from Base, but I want to change a few.
*/
body {
color: #333;
}
a {
text-decoration: none;
color: #1b98f8;
}
/*
* -- HELPER STYLES --
* Over-riding some of the .pure-button styles to make my buttons look unique
*/
.primary-button,
.secondary-button {
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
border-radius: 20px;
}
.primary-button {
color: #fff;
background: #1b98f8;
margin: 1em 0;
}
.secondary-button {
background: #fff;
border: 1px solid #ddd;
color: #666;
padding: 0.5em 2em;
font-size: 80%;
}
/*
* -- LAYOUT STYLES --
* This layout consists of three main elements, `#nav` (navigation bar), `#list` (server list), and `#main` (server content). All 3 elements are within `#layout`
*/
#layout, #nav, #list, #main {
margin: 0;
padding: 0;
}
/* Make the navigation 100% width on phones */
#nav {
width: 100%;
height: 40px;
position: relative;
background: rgb(37, 42, 58);
text-align: center;
}
/* Show the "Menu" button on phones */
#nav .nav-menu-button {
display: block;
top: 0.5em;
right: 0.5em;
position: absolute;
}
/* When "Menu" is clicked, the navbar should be 80% height */
#nav.active {
height: 80%;
}
/* Don't show the navigation items... */
.nav-inner {
display: none;
}
/* ...until the "Menu" button is clicked */
#nav.active .nav-inner {
display: block;
padding: 2em 0;
}
/*
* -- NAV BAR STYLES --
* Styling the default .pure-menu to look a little more unique.
*/
#nav .pure-menu {
background: transparent;
border: none;
text-align: left;
}
#nav .pure-menu-link:hover,
#nav .pure-menu-link:focus {
background: rgb(55, 60, 90);
}
#nav .pure-menu-link {
color: #fff;
margin-left: 0.5em;
}
#nav .pure-menu-heading {
border-bottom: none;
font-size:110%;
color: rgb(75, 113, 151);
}
/*
* -- server STYLES --
* Styles relevant to the server messages, labels, counts, and more.
*/
.server-count {
color: rgb(75, 113, 151);
}
.server-label-personal,
.server-label-work,
.server-label-travel {
width: 15px;
height: 15px;
display: inline-block;
margin-right: 0.5em;
border-radius: 3px;
}
.server-label-personal {
background: #ffc94c;
}
.server-label-work {
background: #41ccb4;
}
.server-label-travel {
background: #40c365;
}
/* server Item Styles */
.server-item {
padding: 0.9em 1em;
border-bottom: 1px solid #ddd;
border-left: 6px solid transparent;
}
.server-name {
text-transform: uppercase;
}
.server-name,
.server-info {
margin: 0;
font-size: 100%;
}
.server-info {
color: #999;
font-size: 80%;
}
.server-comment {
font-size: 90%;
margin: 0.4em 0;
}
.server-add {
cursor: pointer;
text-align: center;
font-size: 150%;
color: #999;
}
.server-add:hover {
color: black;
}
.server-item-selected {
background: #eee;
}
.server-item-unread {
border-left: 6px solid #1b98f8;
}
/* server Content Styles */
.server-content-header, .server-content-body, .server-content-footer {
padding: 1em 2em;
}
.server-content-header {
border-bottom: 1px solid #ddd;
}
.server-content-title {
margin: 0.5em 0 0;
}
.server-content-subtitle {
font-size: 1em;
margin: 0;
font-weight: normal;
}
.server-content-subtitle span {
color: #999;
}
.server-content-controls {
margin-top: 2em;
text-align: right;
}
.server-content-controls .secondary-button {
margin-bottom: 0.3em;
}
.server-avatar {
width: 40px;
height: 40px;
}
/*
* -- TABLET (AND UP) MEDIA QUERIES --
* On tablets and other medium-sized devices, we want to customize some
* of the mobile styles.
*/
@media (min-width: 40em) {
/* Move the layout over so we can fit the nav + list in on the left */
#layout {
padding-left:500px; /* "left col (nav + list)" width */
position: relative;
}
/* These are position:fixed; elements that will be in the left 500px of the screen */
#nav, #list {
position: fixed;
top: 0;
bottom: 0;
overflow: auto;
}
#nav {
margin-left:-500px; /* "left col (nav + list)" width */
width:150px;
height: 100%;
}
/* Show the menu items on the larger screen */
.nav-inner {
display: block;
padding: 2em 0;
}
/* Hide the "Menu" button on larger screens */
#nav .nav-menu-button {
display: none;
}
#list {
margin-left: -350px;
width: 100%;
height: 33%;
border-bottom: 1px solid #ddd;
}
#main {
position: fixed;
top: 33%;
right: 0;
bottom: 0;
left: 150px;
overflow: auto;
width: auto; /* so that it's not 100% */
}
}
/*
* -- DESKTOP (AND UP) MEDIA QUERIES --
* On desktops and other large-sized devices, we want to customize some
* of the mobile styles.
*/
@media (min-width: 60em) {
/* This will take up the entire height, and be a little thinner */
#list {
margin-left: -350px;
width:350px;
height: 100%;
border-right: 1px solid #ddd;
}
/* This will now take up it's own column, so don't need position: fixed; */
#main {
position: static;
margin: 0;
padding: 0;
}
}
.alert {
position: absolute;
top: 1em;
right: 1em;
width: auto;
height: auto;
padding: 10px;
margin: 10px;
line-height: 1.8;
border-radius: 5px;
cursor: hand;
cursor: pointer;
font-family: sans-serif;
font-weight: 400;
}
.alertCheckbox {
display: none;
}
:checked + .alert {
display: none;
}
.alertText {
display: table;
margin: 0 auto;
text-align: center;
font-size: 150%;
}
.alertClose {
float: right;
padding-top: 0px;
font-size: 120%;
}
.clear {
clear: both;
}
.info {
background-color: #EEE;
border: 1px solid #DDD;
color: #999;
}
.success {
background-color: #EFE;
border: 1px solid #DED;
color: #9A9;
}
.notice {
background-color: #EFF;
border: 1px solid #DEE;
color: #9AA;
}
.warning {
background-color: #FDF7DF;
border: 1px solid #FEEC6F;
color: #C9971C;
}
.error {
background-color: #FEE;
border: 1px solid #EDD;
color: #A66;
}

11
static/pure.css Normal file

File diff suppressed because one or more lines are too long

20
templates/add_server.html Normal file
View File

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Add new server{% endblock %}
{% block content %}
<form class="pure-form pure-form-stacked" method="POST">
<fieldset>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<input type="text" class="pure-u-23-24" name="url" placeholder="Server management URL"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<input type="text"class="pure-u-23-24" name="cert" placeholder="Certificate"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<input type="text" class="pure-u-23-24" name="comment" placeholder="Comment"/>
</div>
</div>
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
</fieldset>
</form>
{% endblock %}

90
templates/base.html Normal file
View File

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html>
<head>
{% block title %}Dashboard{% endblock %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='pure.css') }}">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='layout.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="layout" class="content pure-g">
<div id="nav" class="pure-u-1-3">
<a href="#" id="menuLink" class="nav-menu-button">Menu</a>
<div class="nav-inner">
<button onclick="location.href='/';" style="cursor:pointer;" class="primary-button pure-button">OutlineFleet</button>
<div class="pure-menu custom-restricted-width">
<ul class="pure-menu-list">
<li class="pure-menu-item"><a href="/" class="pure-menu-link">Servers</a></li>
<li class="pure-menu-item"><a href="#" class="pure-menu-link">Clients</a></li>
</ul>
</div>
</div>
</div>
{% block content %}{% endblock %}
</div>
<!-- Script to make the Menu link work -->
<!-- Just stripped down version of the js/ui.js script for the side-menu layout -->
<script>
function getElements() {
return {
menu: document.getElementById('nav'),
menuLink: document.getElementById('menuLink')
};
}
function toggleClass(element, className) {
var classes = element.className.split(/\s+/);
var length = classes.length;
var i = 0;
for (; i < length; i++) {
if (classes[i] === className) {
classes.splice(i, 1);
break;
}
}
// The className is not found
if (length === classes.length) {
classes.push(className);
}
element.className = classes.join(' ');
}
function toggleMenu() {
var active = 'active';
var elements = getElements();
toggleClass(elements.menu, active);
}
function handleEvent(e) {
var elements = getElements();
if (e.target.id === elements.menuLink.id) {
toggleMenu();
e.preventDefault();
} else if (elements.menu.className.indexOf('active') !== -1) {
toggleMenu();
}
}
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('click', handleEvent);
});
</script>
{% if nt %}
<label>
<input type="checkbox" class="alertCheckbox" autocomplete="off" />
<div class="alert {% if nl == 'error' %}error{% else %}success{% endif %}">
<span class="alertText">{{nt}}
<br class="clear"/></span>
</div>
</label>
{% endif %}
</body>
</html>

105
templates/index.html Normal file
View File

@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block content %}
<div id="list" class="pure-u-1-3" xmlns="http://www.w3.org/1999/html">
<div class="server-item pure-g">
<h1 class="server-content-title">Servers</h1>
</div>
{% for server in SERVERS %}
<div class="server-item server-item-{% if loop.index0 == selected_server|int %}unread{% else %}selected{% endif %} pure-g">
<div class="pure-u-3-4" onclick="location.href='/?selected_server={{loop.index0}}';">
<h5 class="server-name">{{ server.info()["name"] }}</h5>
<h4 class="server-info">{{ '/'.join(server.info()["url"].split('/')[0:-1]) }}</h4>
<h4 class="server-info">Port {{ server.info()["port_for_new_access_keys"] }}</h4>
<h4 class="server-info">Hostname {{ server.info()["hostname_for_access_keys"] }}</h4>
<h4 class="server-info">v.{{ server.info()["version"] }}</h4>
<p class="server-comment">
{{ server.info()["comment"] }}
</p>
</div>
</div>
{% endfor %}
<div onclick="location.href='/add_server';" class="server-item server-add pure-g">
<div class="pure-u-1">
+
</div>
</div>
</div>
{% if SERVERS %}
{% if server is none %}
{% set server = SERVERS[0] %}
{% else %}
{% set server = SERVERS[selected_server|int] %}
{% endif %}
<div id="main" class="pure-u-1-3">
<div class="server-content">
<div class="server-content-header pure-g">
<div class="pure-u-1-2">
<h1 class="server-content-title">{{server.info()["name"]}}</h1>
<p class="server-content-subtitle">
<span>v.{{server.info()["version"]}} {{server.info()["server_id"]}}</span>
</p>
</div>
<!-- <div class="server-content-controls pure-u-1-2">-->
<!-- <button class="secondary-button pure-button">Reply</button>-->
<!-- <button class="secondary-button pure-button">Forward</button>-->
<!-- <button class="secondary-button pure-button">Move to</button>-->
<!-- </div>-->
</div>
<div class="server-content-body">
<h3>Clients: {{ server.info()['keys']|length }}</h3>
<form class="pure-form pure-form-stacked" method="POST">
<fieldset>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="name">Server Name</br> Note that this will not be reflected on the devices of the users that you invited to connect to it.</label>
<input type="text" id="name" class="pure-u-23-24" name="name" value="{{server.info()['name']}}"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="url">Server URL</label>
<input type="text" readonly id="url" class="pure-u-23-24" name="url" value="{{server.info()['url']}}"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="comment">Comment</label>
<input type="text" id="comment" class="pure-u-23-24" name="comment" value="{{server.info()['comment']}}"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="port_for_new_access_keys">Port For New Access Keys</label>
<input type="text" id="port_for_new_access_keys" class="pure-u-23-24" name="port_for_new_access_keys" value="{{server.info()['port_for_new_access_keys']}}"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="hostname_for_access_keys">Hostname For Access Keys</label>
<input type="text" id="hostname_for_access_keys" class="pure-u-23-24" name="hostname_for_access_keys" value="{{server.info()['hostname_for_access_keys']}}"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="cert">Server Access Certificate</label>
<input type="text" readonly id="cert" class="pure-u-23-24" name="cert" value="{{server.info()['cert']}}"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="created_timestamp_ms">Created</label>
<input type="text" readonly id="created_timestamp_ms" class="pure-u-23-24" name="created_timestamp_ms" value="{{format_timestamp(server.info()['created_timestamp_ms']) }}"/>
</div>
<input type="hidden" readonly id="server_id" class="pure-u-23-24" name="server_id" value="{{server.info()['server_id']}}"/>
</div>
<p>Share anonymous metrics</p>
<label for="metrics_enabled" class="pure-radio">
<input type="radio" id="metrics_enabled" name="metrics" value="True" {% if server.info()['metrics_enabled'] == True %}checked{% endif %} /> Enable
</label>
<label for="metrics_disabled" class="pure-radio">
<input type="radio" id="metrics_disabled" name="metrics" value="False" {% if server.info()['metrics_enabled'] == False %}checked{% endif %} /> Disable
</label>
<button type="submit" class="pure-button pure-button-primary">Submit</button>
</fieldset>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}