1 Commits

Author SHA1 Message Date
c7a41e6a2f Init api 2024-03-27 20:03:34 +02:00
11 changed files with 361 additions and 800 deletions

View File

@@ -82,15 +82,6 @@ server {
} }
``` ```
#### Setup sslocal service on Windows
Shadowsocks servers can be used directly with **sslocal**. For automatic and regular password updates, you can create a Task Scheduler job to rotate the passwords when they change, as OutFleet manages the passwords automatically.
You may run script in Admin PowerShell to create Task for autorun **sslocal** and update connection details automatically using Outfleet API
```PowerShell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force; Invoke-Expression (Invoke-WebRequest -Uri "https://raw.githubusercontent.com/house-of-vanity/OutFleet/refs/heads/master/tools/windows-helper.ps1" -UseBasicParsing).Content
```
[Firefox PluginProxy Switcher and Manager](https://addons.mozilla.org/en-US/firefox/addon/proxy-switcher-and-manager/) && [Chrome plugin Proxy Switcher and Manager](https://chromewebstore.google.com/detail/proxy-switcher-and-manage/onnfghpihccifgojkpnnncpagjcdbjod)
Keep in mind that all user keys are stored in a single **config.yaml** file. If this file is lost, user keys will remain on the servers, but OutFleet will lose the ability to manage them. Handle with extreme caution and use backups. Keep in mind that all user keys are stored in a single **config.yaml** file. If this file is lost, user keys will remain on the servers, but OutFleet will lose the ability to manage them. Handle with extreme caution and use backups.
## Authors ## Authors

3
k8s.py
View File

@@ -92,8 +92,9 @@ def write_config(config):
def reload_config(): def reload_config():
global CONFIG global CONFIG
while True: while True:
new_config = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
with lib.lock: with lib.lock:
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml']) CONFIG = new_config
log.debug(f"Synced system config with ConfigMap [config-outfleet].") log.debug(f"Synced system config with ConfigMap [config-outfleet].")
time.sleep(30) time.sleep(30)

11
lib.py
View File

@@ -33,19 +33,10 @@ def get_config():
try: try:
with open(args.config, "r") as file: with open(args.config, "r") as file:
config = yaml.safe_load(file) config = yaml.safe_load(file)
if config == None:
config = {
"servers": {},
"clients": {}
}
except: except:
try: try:
with open(args.config, "w"): with open(args.config, "w"):
config = { pass
"servers": {},
"clients": {}
}
yaml.safe_dump(config, file)
except Exception as exp: except Exception as exp:
log.error(f"Couldn't create config. {exp}") log.error(f"Couldn't create config. {exp}")
return None return None

138
main.py
View File

@@ -5,26 +5,14 @@ import logging
from datetime import datetime from datetime import datetime
import random import random
import string import string
import argparse
import uuid import uuid
import bcrypt
import k8s import k8s
from flask import Flask, render_template, request, url_for, redirect from flask import Flask, render_template, request, url_for, redirect, jsonify
from flask_cors import CORS from flask_cors import CORS
from werkzeug.routing import BaseConverter
from lib import Server, write_config, get_config, args, lock from lib import Server, write_config, get_config, args, lock
class DuplicateFilter(logging.Filter):
def filter(self, record):
# add other fields if you need more granular comparison, depends on your app
current_log = (record.module, record.levelno, record.msg)
if current_log != getattr(self, "last_log", None):
self.last_log = current_log
return True
return False
logging.getLogger("werkzeug").setLevel(logging.ERROR) logging.getLogger("werkzeug").setLevel(logging.ERROR)
@@ -41,20 +29,14 @@ formatter = logging.Formatter(
) )
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
log.addHandler(file_handler) log.addHandler(file_handler)
duplicate_filter = DuplicateFilter()
log.addFilter(duplicate_filter)
CFG_PATH = args.config CFG_PATH = args.config
NAMESPACE = k8s.NAMESPACE NAMESPACE = k8s.NAMESPACE
SERVERS = list() SERVERS = list()
BROKEN_SERVERS = list() BROKEN_SERVERS = list()
CLIENTS = dict() CLIENTS = dict()
VERSION = '8.1' VERSION = '5'
SECRET_LINK_LENGTH = 8
SECRET_LINK_PREFIX = '$2b$12$'
SS_PREFIX = "\u0005\u00DC\u005F\u00E0\u0001\u0020"
HOSTNAME = "" HOSTNAME = ""
WRONG_DOOR = "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
@@ -69,7 +51,9 @@ def random_string(length=64):
return "".join(random.choice(letters) for i in range(length)) return "".join(random.choice(letters) for i in range(length))
def update_state(timer=40): def update_state(timer=40):
while True: while True:
with lock: with lock:
global SERVERS global SERVERS
@@ -109,7 +93,6 @@ def update_state(timer=40):
break break
time.sleep(40) time.sleep(40)
@app.route("/", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def index(): def index():
if request.method == "GET": if request.method == "GET":
@@ -144,6 +127,33 @@ def index():
else: else:
return redirect(url_for("index")) return redirect(url_for("index"))
@app.route("/servers", methods=["GET", "POST"])
@app.route("/servers/<string:local_server_id>", methods=["GET", "POST", "DELETE"])
def servers(local_server_id=None):
if local_server_id:
log.info(f"Got {local_server_id} Config {get_config()}")
server = get_config()['servers'].get(local_server_id, None)
return jsonify(server)
if request.method == "GET":
return jsonify({ "servers": get_config()['servers']})
elif request.method == "POST":
server = request.form["server_id"]
server = next(
(item for item in SERVERS if item.info()["local_server_id"] == server), None
)
server.apply_config(request.form)
update_state(timer=0)
return redirect(
url_for(
"index",
nt="Updated Outline VPN Server",
selected_server=request.args.get("selected_server"),
)
)
else:
return redirect(url_for("index"))
@app.route("/clients", methods=["GET", "POST"]) @app.route("/clients", methods=["GET", "POST"])
def clients(): def clients():
@@ -151,11 +161,8 @@ def clients():
return render_template( return render_template(
"clients.html", "clients.html",
SERVERS=SERVERS, SERVERS=SERVERS,
bcrypt=bcrypt,
CLIENTS=CLIENTS, CLIENTS=CLIENTS,
VERSION=VERSION, VERSION=VERSION,
SECRET_LINK_LENGTH=SECRET_LINK_LENGTH,
SECRET_LINK_PREFIX=SECRET_LINK_PREFIX,
K8S_NAMESPACE=k8s.NAMESPACE, K8S_NAMESPACE=k8s.NAMESPACE,
nt=request.args.get("nt"), nt=request.args.get("nt"),
nl=request.args.get("nl"), nl=request.args.get("nl"),
@@ -199,7 +206,6 @@ def add_server():
) )
) )
@app.route("/del_server", methods=["POST"]) @app.route("/del_server", methods=["POST"])
def del_server(): def del_server():
if request.method == "POST": if request.method == "POST":
@@ -321,70 +327,8 @@ def del_client():
return redirect(url_for("clients", nt="User has been deleted")) return redirect(url_for("clients", nt="User has been deleted"))
def append_to_log(log_entry): @app.route("/dynamic/<server_name>/<client_id>", methods=["GET"], strict_slashes=False)
with open("access_log.log", "a") as log_file: def dynamic(server_name, client_id):
log_file.write(log_entry + "\n")
@app.route("/dynamic/<path:hash_secret>", methods=["GET"], strict_slashes=False)
def dynamic(hash_secret):
# Depricated scheme.
for server in SERVERS:
if hash_secret.startswith(server.data["name"]):
log.warning("Deprecated key request")
server_name = hash_secret.split('/')[0]
client_id = hash_secret.split('/')[1]
return dynamic_depticated(server_name, client_id, hash_secret)
try:
short_hash_server = hash_secret[0:SECRET_LINK_LENGTH]
short_hash_client = hash_secret[SECRET_LINK_LENGTH:SECRET_LINK_LENGTH * 2 ]
client_provided_secret = hash_secret[SECRET_LINK_LENGTH * 2:]
hash_server = None
hash_client = None
server = None
client = None
for _server in SERVERS:
if _server.data["local_server_id"][:SECRET_LINK_LENGTH] == short_hash_server:
hash_server = _server.data["local_server_id"]
server = _server
for client_id, values in CLIENTS.items():
if client_id[:SECRET_LINK_LENGTH] == short_hash_client:
hash_client = client_id
client = CLIENTS[client_id]
if server and client:
append_to_log(f"User: {client["name"]}. Server: {server.data['name']} client secret string: {hash_secret}")
client_shadowsocks_key = next(
(item for item in server.data["keys"] if item.key_id == client["name"]), None
)
secret_string = hash_server + hash_client
check_secret_hash = bcrypt.checkpw(
password=secret_string.encode('utf-8'),
hashed_password=f"{SECRET_LINK_PREFIX}{client_provided_secret}".encode('utf-8')
)
if check_secret_hash:
log.info(f"Client {client['name']} has been requested ssconf for {server.data['name']}. Bcrypt client hash {client_provided_secret[0:16]}...[FULL HASH SECURED]")
return {
"server": server.data["hostname_for_access_keys"],
"server_port": client_shadowsocks_key.port,
"password": client_shadowsocks_key.password,
"method": client_shadowsocks_key.method,
"prefix": SS_PREFIX,
"info": "Managed by OutFleet [github.com/house-of-vanity/OutFleet/]",
}
else:
log.warning(f"Hack attempt! Client secret does not match: {client_provided_secret}")
return WRONG_DOOR
else:
log.warning(f"Hack attempt! Client or server doesn't exist. payload: {hash_secret[0:200]}")
return WRONG_DOOR
except Exception as e:
log.error(f"Dynamic V2 parse error: {e}")
return WRONG_DOOR
def dynamic_depticated(server_name, client_id, hash_secret=""):
try: try:
client = next( client = next(
(keys for client, keys in CLIENTS.items() if client == client_id), None (keys for client, keys in CLIENTS.items() if client == client_id), None
@@ -398,15 +342,13 @@ def dynamic_depticated(server_name, client_id, hash_secret=""):
if server and client and key: if server and client and key:
if server.data["local_server_id"] in client["servers"]: if server.data["local_server_id"] in client["servers"]:
log.info( log.info(
"Client %s has been requested ssconf for %s", client["name"], server.data["name"] "Client %s wants ssconf for %s", client["name"], server.data["name"]
) )
append_to_log(f"User: {client["name"]}. Server: {server.data['name']} client secret string: {hash_secret}")
return { return {
"server": server.data["hostname_for_access_keys"], "server": server.data["hostname_for_access_keys"],
"server_port": key.port, "server_port": key.port,
"password": key.password, "password": key.password,
"method": key.method, "method": key.method,
"prefix":SS_PREFIX,
"info": "Managed by OutFleet [github.com/house-of-vanity/OutFleet/]", "info": "Managed by OutFleet [github.com/house-of-vanity/OutFleet/]",
} }
else: else:
@@ -415,17 +357,18 @@ def dynamic_depticated(server_name, client_id, hash_secret=""):
client["name"], client["name"],
server.data["name"], server.data["name"],
) )
return WRONG_DOOR return "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
except: except:
log.warning("Hack attempt! Client or server doesn't exist. SCAM") log.warning("Hack attempt! Client or server doesn't exist. SCAM")
return WRONG_DOOR return "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
@app.route("/dynamic/", methods=["GET"], strict_slashes=False)
@app.route("/dynamic", methods=["GET"], strict_slashes=False)
def _dynamic(): def _dynamic():
log.warning("Hack attempt! Client or server doesn't exist. SCAM") log.warning("Hack attempt! Client or server doesn't exist. SCAM")
return WRONG_DOOR return (
"Hey buddy, i think you got the wrong door the leather-club is two blocks down"
)
@app.route("/sync", methods=["GET", "POST"]) @app.route("/sync", methods=["GET", "POST"])
@@ -475,7 +418,6 @@ def sync():
return redirect(url_for("sync")) return redirect(url_for("sync"))
if __name__ == "__main__": if __name__ == "__main__":
update_state_thread = threading.Thread(target=update_state) update_state_thread = threading.Thread(target=update_state)
update_state_thread.start() update_state_thread.start()

View File

@@ -3,4 +3,3 @@ kubernetes
PyYAML>=6.0.1 PyYAML>=6.0.1
Flask>=2.3.3 Flask>=2.3.3
flask-cors flask-cors
bcrypt

View File

@@ -1,11 +1,3 @@
:root {
--app-h: 100vh;
--app-space-1: 8px;
}
/* /*
* -- BASE STYLES -- * -- BASE STYLES --
* Most of these are inherited from Base, but I want to change a few. * Most of these are inherited from Base, but I want to change a few.
@@ -30,7 +22,6 @@ a {
.button { .button {
border-radius: 4px; border-radius: 4px;
} }
.delete-button { .delete-button {
background: #9d2c2c; background: #9d2c2c;
border: 1px solid #480b0b; border: 1px solid #480b0b;
@@ -41,8 +32,7 @@ a {
* -- LAYOUT STYLES -- * -- 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` * This layout consists of three main elements, `#nav` (navigation bar), `#list` (server list), and `#main` (server content). All 3 elements are within `#layout`
*/ */
#nav, #layout, #nav, #list, #main {
#main {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
@@ -55,7 +45,6 @@ a {
background: rgb(37, 42, 58); background: rgb(37, 42, 58);
text-align: center; text-align: center;
} }
/* Show the "Menu" button on phones */ /* Show the "Menu" button on phones */
#nav .nav-menu-button { #nav .nav-menu-button {
display: block; display: block;
@@ -66,9 +55,8 @@ a {
/* When "Menu" is clicked, the navbar should be 80% height */ /* When "Menu" is clicked, the navbar should be 80% height */
#nav.active { #nav.active {
height: 100%; height: 80%;
} }
/* Don't show the navigation items... */ /* Don't show the navigation items... */
.nav-inner { .nav-inner {
display: none; display: none;
@@ -90,17 +78,14 @@ a {
border: none; border: none;
text-align: left; text-align: left;
} }
#nav .pure-menu-link:hover, #nav .pure-menu-link:hover,
#nav .pure-menu-link:focus { #nav .pure-menu-link:focus {
background: rgb(55, 60, 90); background: rgb(55, 60, 90);
} }
#nav .pure-menu-link { #nav .pure-menu-link {
color: #fff; color: #fff;
margin-left: 0.5em; margin-left: 0.5em;
} }
#nav .pure-menu-heading { #nav .pure-menu-heading {
border-bottom: none; border-bottom: none;
font-size:110%; font-size:110%;
@@ -125,63 +110,45 @@ a {
margin-right: 0.5em; margin-right: 0.5em;
border-radius: 3px; border-radius: 3px;
} }
.server-label-personal { .server-label-personal {
background: #ffc94c; background: #ffc94c;
} }
.server-label-work { .server-label-work {
background: #41ccb4; background: #41ccb4;
} }
.server-label-travel { .server-label-travel {
background: #40c365; background: #40c365;
} }
/* server Item Styles */ /* server Item Styles */
.server-content-title {
padding: var(--app-space-1);
/* color: #ffffff; */
}
.server-item { .server-item {
padding: var(--app-space-1); padding: 0.9em 1em;
/* color: #ffffff; */ border-bottom: 1px solid #ddd;
cursor: pointer; border-left: 6px solid transparent;
} }
.server-item:hover {
background: #d1d0d0;
}
.server-name { .server-name {
text-transform: uppercase; text-transform: uppercase;
} }
.server-name, .server-name,
.server-info { .server-info {
margin: 0; margin: 0;
font-size: 100%; font-size: 100%;
} }
.server-info { .server-info {
color: #999; color: #999;
font-size: 80%; font-size: 80%;
} }
.server-comment { .server-comment {
font-size: 90%; font-size: 90%;
margin: 0.4em 0; margin: 0.4em 0;
} }
.server-add { .server-add {
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
font-size: 150%; font-size: 150%;
color: #999; color: #999;
} }
.server-add:hover { .server-add:hover {
color: black; color: black;
} }
@@ -189,23 +156,21 @@ a {
.server-item-selected { .server-item-selected {
background: #eeeeee; background: #eeeeee;
} }
.server-item-unread { .server-item-unread {
border-left: 6px solid #1b98f8; border-left: 6px solid #1b98f8;
} }
.server-item-broken { .server-item-broken {
border-left: 6px solid #880d06; border-left: 6px solid #880d06;
} }
.server-item:hover {
/* server Content Styles */ background: #d1d0d0;
.server-content-header,
.server-content-body,
.server-content-footer {
padding: 1em 2em;
} }
/* server Content Styles */
.server-content-header, .server-content-body, .server-content-footer {
padding: 1em 2em;
}
.server-content-header { .server-content-header {
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;
} }
@@ -213,22 +178,18 @@ a {
.server-content-title { .server-content-title {
margin: 0.5em 0 0; margin: 0.5em 0 0;
} }
.server-content-subtitle { .server-content-subtitle {
font-size: 1em; font-size: 1em;
margin: 0; margin: 0;
font-weight: normal; font-weight: normal;
} }
.server-content-subtitle span { .server-content-subtitle span {
color: #999; color: #999;
} }
.server-content-controls { .server-content-controls {
margin-top: 2em; margin-top: 2em;
text-align: right; text-align: right;
} }
.server-content-controls .secondary-button { .server-content-controls .secondary-button {
margin-bottom: 0.3em; margin-bottom: 0.3em;
} }
@@ -246,16 +207,21 @@ a {
*/ */
@media (min-width: 40em) { @media (min-width: 40em) {
/* Move the layout over so we can fit the nav + list in on the left */
#layout {
padding-left:500px;
position: relative;
}
/* These are position:fixed; elements that will be in the left 500px of the screen */ /* These are position:fixed; elements that will be in the left 500px of the screen */
#nav { #nav, #list {
position: fixed; position: fixed;
top: 0; top: 0;
bottom: 0; bottom: 0;
overflow: auto; overflow: auto;
} }
#nav { #nav {
/* margin-left: -500px; */ margin-left:-500px;
width:150px; width:150px;
height: 100%; height: 100%;
} }
@@ -271,6 +237,12 @@ a {
display: none; display: none;
} }
#list {
margin-left: -350px;
width: 100%;
height: 33%;
border-bottom: 1px solid #ddd;
}
#main { #main {
position: fixed; position: fixed;
@@ -279,8 +251,7 @@ a {
bottom: 0; bottom: 0;
left: 150px; left: 150px;
overflow: auto; overflow: auto;
width: auto; width: auto; /* so that it's not 100% */
/* so that it's not 100% */
} }
} }
@@ -293,7 +264,12 @@ a {
@media (min-width: 60em) { @media (min-width: 60em) {
/* This will take up the entire height, and be a little thinner */ /* 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; */ /* This will now take up it's own column, so don't need position: fixed; */
#main { #main {
@@ -374,102 +350,3 @@ a {
border: 1px solid #EDD; border: 1px solid #EDD;
color: #A66; color: #A66;
} }
#layout {
display: grid;
grid-template-areas:
"menu entety-menu content";
height: var(--app-h);
justify-content: left;
overflow: hidden;
}
#menu {
grid-area: menu;
width: 150px;
height: 100%;
background-color: #333;
}
#entety-menu {
grid-area: entety-menu;
width: 300px;
height: 100%;
background-color: #fff;
}
#content {
grid-area: content;
height: 100%;
overflow: auto;
}
.srcollable-list-content {
overflow-y: auto;
}
@media (max-width: 60em) {
#entety-menu {
width: 200px;
}
}
@media (max-width: 40em) {
#layout {
display: grid;
grid-template-areas:
"menu"
"entety-menu"
"content";
grid-template-rows: min-content min-content;
}
#menu {
width: 100vw;
height: fit-content;
}
#entety-menu {
width: 100vw;
height: initial;
}
.srcollable-list-content{
display: flex;
overflow-y: auto;
scroll-snap-type: x mandatory;
column-gap: var(--app-space-1);
}
.srcollable-list-content > * {
scroll-snap-align: start;
flex-grow: 0;
flex-shrink: 0;
flex-basis: 250px;
}
}
.hidden {
display: none;
}
.server-checkbox {
display: flex;
}
.server-checkbox input {
margin-right: 8px;
}
.pure-form-message {
padding: 0.2em;
}
#search-form {
padding: var(--app-space-1);
}
#entety-menu-search {
width: 100%;
}

File diff suppressed because one or more lines are too long

View File

@@ -2,25 +2,18 @@
{% block content %} {% block content %}
<div id="entety-menu"> <div id="list" class="pure-u-1-3" xmlns="http://www.w3.org/1999/html" xmlns="http://www.w3.org/1999/html">
<div id="list"> <div class="server-item pure-g">
<h1 class="server-content-title">Clients</h1> <h1 class="server-content-title">Clients</h1>
<form id="search-form" class="pure-form"> </div>
<input placeholder="&#128269;" type="text" id="entety-menu-search" />
</form>
<div data-search-box class="srcollable-list-content">
{% for client, values in CLIENTS.items() %} {% for client, values in CLIENTS.items() %}
<div <div class="server-item server-item-{% if client == selected_client %}unread{% else %}selected{% endif %} pure-g">
class="server-item server-item-{% if client == selected_client %}unread{% else %}selected{% endif %} pure-g"> <div class="pure-u-3-4" onclick="location.href='/clients?selected_client={{ client }}';">
<div class="" onclick="location.href='/clients?selected_client={{ client }}';"> <h5 class="server-name">{{ values["name"] }}</h5>
<h5 data-search class="server-name">{{ values["name"] }}</h5> <h4 class="server-info">Allowed {{ values["servers"]|length }} server{% if values["servers"]|length >1 %}s{%endif%}</h4>
<h4 class="server-info">{{ values["servers"]|length }} server{% if values["servers"]|length >1
%}s{%endif%}</h4>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div>
<div onclick="location.href='/clients?add_client=True';" class="server-item server-add pure-g"> <div onclick="location.href='/clients?add_client=True';" class="server-item server-add pure-g">
<div class="pure-u-1"> <div class="pure-u-1">
+ +
@@ -28,13 +21,11 @@
</div> </div>
</div> </div>
</div>
{% if add_client %} {% if add_client %}
<div id="content"> <div class="pure-u-1-3">
<div class="">
<div class="server-content-header pure-g"> <div class="server-content-header pure-g">
<div class=""> <div class="pure-u-1-2">
<h1 class="server-content-title">Add new client</h1> <h1 class="server-content-title">Add new client</h1>
</div> </div>
</div> </div>
@@ -42,22 +33,17 @@
<form action="/add_client" class="pure-form pure-form-stacked" method="POST"> <form action="/add_client" class="pure-form pure-form-stacked" method="POST">
<fieldset> <fieldset>
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<input type="text" class="" name="name" required placeholder="Name" /> <input type="text" class="pure-u-23-24" name="name" required placeholder="Name"/>
</div> </div>
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<input type="text" class="" name="comment" placeholder="Comment" /> <input type="text" class="pure-u-23-24" name="comment" placeholder="Comment"/>
</div> </div>
<div class="pure-checkbox"> <div class="pure-checkbox">
{% for server in SERVERS %} {% for server in SERVERS %}
<div class="server-checkbox"> <label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["name"]}}
<input type="checkbox" id="option{{loop.index0}}" name="servers" <input type="checkbox" id="option{{loop.index0}}" name="servers" value="{{server.info()['local_server_id']}}"></label>
value="{{server.info()['local_server_id']}}">
<label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["comment"]}}
<span class="pure-form-message">ID: {{server.info()['local_server_id'][0:8]}}</span>
<span class="pure-form-message">Comment: {{server.info()["name"]}}</span>
</label>
</div>
{% endfor %} {% endfor %}
</div> </div>
@@ -68,17 +54,15 @@
</form> </form>
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}
{% if selected_client and not add_client %} {% if selected_client and not add_client %}
{% set client = CLIENTS[selected_client] %} {% set client = CLIENTS[selected_client] %}
<div id="content"> <div class="pure-u-1-2">
<div class="">
<div class="server-content-header pure-g"> <div class="server-content-header pure-g">
<div class=""> <div class="pure-u-1-2">
<h1 class="server-content-title">{{client['name']}}</h1> <h1 class="server-content-title">{{client['name']}}</h1>
<h4 class="server-info">{{ client['comment'] }}</h4> <h4 class="server-info">{{ client['comment'] }}</h4>
<h4 class="server-info">id {{ selected_client }}</h4> <h4 class="server-info">id {{ selected_client }}</h4>
@@ -89,77 +73,100 @@
<form action="/add_client" class="pure-form pure-form-stacked" method="POST"> <form action="/add_client" class="pure-form pure-form-stacked" method="POST">
<fieldset> <fieldset>
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<input type="text" class="pure-u-1" name="name" required value="{{client['name']}}"/> <input type="text" class="pure-u-1" name="name" required value="{{client['name']}}"/>
<input type="hidden" class="pure-u-1" name="old_name" required value="{{client['name']}}"/> <input type="hidden" class="pure-u-1" name="old_name" required value="{{client['name']}}"/>
</div> </div>
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<input type="text" class="pure-u-1" name="comment" value="{{client['comment']}}"/> <input type="text" class="pure-u-1" name="comment" value="{{client['comment']}}"/>
</div> </div>
<input type="hidden" class="pure-u-1" name="user_id" value="{{selected_client}}"/> <input type="hidden" class="pure-u-1" name="user_id" value="{{selected_client}}"/>
<div class="pure-checkbox"> <div class="pure-checkbox">
<p>Allow access to:</p> <p>Allow access to:</p>
{% for server in SERVERS %} {% for server in SERVERS %}
<div class="server-checkbox"> <label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["name"]}}{% if server.info()['local_server_id'] in client['servers'] %} ( Used {% for key in server.data["keys"] %}{% if key.name == client['name'] %}{{ (key.used_bytes if key.used_bytes else 0) | filesizeformat }}{% endif %}{% endfor %}){%endif%}
<input {% if server.info()['local_server_id'] in client['servers'] %}checked{%endif%} <input
type="checkbox" id="option{{loop.index0}}" name="servers" {% if server.info()['local_server_id'] in client['servers'] %}checked{%endif%}
value="{{server.info()['local_server_id']}}"> type="checkbox" id="option{{loop.index0}}" name="servers" value="{{server.info()['local_server_id']}}"></label>
<label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["comment"]}}
<span class="pure-form-message">ID: {{server.info()['local_server_id'][0:8]}}</span>
<span class="pure-form-message">Comment: {{server.info()["name"]}}</span>
<span class="pure-form-message">{% if
server.info()['local_server_id'] in client['servers'] %}Usage: {% for key in
server.data["keys"] %}{% if key.name == client['name'] %}{{ (key.used_bytes if
key.used_bytes else 0) | filesizeformat }}{% endif %}{% endfor
%}{%endif%}</span>
</label>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="pure-g pure-form pure-form-stacked"> <button type="submit" class="pure-button pure-input-1 pure-button-primary">Save and apply</button>
<div class="pure-u-1-2">
<button type="submit" class="pure-button pure-button-primary button">Save and apply</button>
</div>
</div>
</fieldset> </fieldset>
</form> </form>
<form action="/del_client" class="pure-form pure-form-stacked" method="POST">
<input type="hidden" name="name" required value="{{client['name']}}" />
<input type="hidden" name="user_id" value="{{selected_client}}" />
<button type="submit" class="pure-button pure-button-primary delete-button button">Delete
Client</button>
<input type="checkbox" id="agree" name="agree" required>
</form>
<div> <div>
<h3>Invite text</h3> <h3>Invite text</h3><hr>
<hr> <textarea style="width: 100%; rows=10">
<p>Install Outline VPN. Copy and paste the keys below into the Outline client. Install OutLine VPN. Copy and paste below keys to OutLine client.
The same keys can be used simultaneously on multiple devices.</p> Same keys will work simultaneously on many devices.
{% for server in SERVERS -%} {% for server in SERVERS -%}
{% if server.info()['local_server_id'] in client['servers'] %} {% if server.info()['local_server_id'] in client['servers'] %}
{% set salt = bcrypt.gensalt() %} {{server.info()['name']}}
{% set secret_string = server.info()['local_server_id'] + selected_client %} ```{% for key in server.data["keys"] %}{% if key.key_id == client['name'] %}ssconf://{{ dynamic_hostname }}/dynamic/{{server.info()['name']}}/{{selected_client}}#{{server.info()['comment']}}{% endif %}{% endfor %}```
{% set hash_secret = bcrypt.hashpw(
password=secret_string.encode('utf-8'),
salt=salt).decode('utf-8') %}
<p><b>Server location:</b> {{server.info()['name']}}</p>
<p><b>Client link:</b> {% for key in server.data["keys"] %}{% if key.key_id == client['name']
%}ssconf://{{ dynamic_hostname
}}/dynamic/{{server.info()['local_server_id'][0:SECRET_LINK_LENGTH]}}{{selected_client[0:SECRET_LINK_LENGTH]}}{{hash_secret[SECRET_LINK_PREFIX|length:]}}#{{server.info()['comment']}}{%
endif %}{% endfor %}</p>
{% endif %} {% endif %}
{%- endfor -%} {%- endfor -%}</textarea>
</div> </div>
<hr>
<div style="padding-top: 15px; padding-bottom: 15px">
<div class="pure-u-1">
<h3>Dynamic Access Keys</h3>
<table class="pure-table">
<thead>
<tr>
<th>Server</th>
<th>Dynamic</th>
</tr>
</thead>
<tbody>
{% for server in SERVERS %}
{% if server.info()['local_server_id'] in client['servers'] %}
<tr>
<td>{{ server.info()['name'] }}</td>
<td>
<p style="font-size: 10pt">{% for key in server.data["keys"] %}{% if key.key_id == client['name'] %}ssconf://{{ dynamic_hostname }}/dynamic/{{server.info()['name']}}/{{selected_client}}#{{server.info()['comment']}}{% endif %}{% endfor %}</p>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div> </div>
<div class="pure-u-1 pure-u-md-1">
<h3>SS Links</h3>
<table class="pure-table">
<thead>
<tr>
<th>Server</th>
<th>SSlink</th>
</tr>
</thead>
<tbody>
{% for server in SERVERS %}
{% if server.info()['local_server_id'] in client['servers'] %}
<tr>
<td>{{ server.info()['name'] }}</td>
<td>
<pre style="font-size: 10pt">{% for key in server.data["keys"] %}{% if key.key_id == client['name'] %}{{ key.access_url }}{% endif %}{% endfor %}</pre>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
<hr>
</div>
<form action="/del_client" class="pure-form pure-form-stacked" method="POST">
<input type="hidden" class="pure-u-1" name="name" required value="{{client['name']}}"/>
<input type="hidden" class="pure-u-1" name="user_id" value="{{selected_client}}"/>
<button type="submit" class="pure-button button-error pure-input-1 ">Delete</button>
</form>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@@ -2,17 +2,15 @@
{% block content %} {% block content %}
<div id="list" class="pure-u-1-3" xmlns="http://www.w3.org/1999/html">
<div class="server-item pure-g">
<div id="entety-menu">
<div>
<h1 class="server-content-title">Servers</h1> <h1 class="server-content-title">Servers</h1>
<div class="srcollable-list-content"> </div>
{% for server in SERVERS %} {% for server in SERVERS %}
{% set total_traffic = namespace(total_bytes=0) %} {% set list_ns = namespace(total_bytes=0) %}
{% for key in server.data["keys"] %} {% for key in server.data["keys"] %}
{% if key.used_bytes %} {% if key.used_bytes %}
{% set total_traffic.total_bytes = total_traffic.total_bytes + key.used_bytes %} {% set list_ns.total_bytes = list_ns.total_bytes + key.used_bytes %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="server-item server-item-{% if loop.index0 == selected_server|int %}unread{% else %}selected{% endif %} pure-g"> <div class="server-item server-item-{% if loop.index0 == selected_server|int %}unread{% else %}selected{% endif %} pure-g">
@@ -21,7 +19,7 @@
<h4 class="server-info">{{ '/'.join(server.info()["url"].split('/')[0:-1]) }}</h4> <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">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">Hostname {{ server.info()["hostname_for_access_keys"] }}</h4>
<h4 class="server-info">Traffic: {{ total_traffic.total_bytes | filesizeformat }}</h4> <h4 class="server-info">Traffic: {{ list_ns.total_bytes | filesizeformat }}</h4>
<h4 class="server-info">v.{{ server.info()["version"] }}</h4> <h4 class="server-info">v.{{ server.info()["version"] }}</h4>
<p class="server-comment"> <p class="server-comment">
{{ server.info()["comment"] }} {{ server.info()["comment"] }}
@@ -29,7 +27,6 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div>
<div onclick="location.href='/?add_server=True';" class="server-item server-add pure-g"> <div onclick="location.href='/?add_server=True';" class="server-item server-add pure-g">
<div class="pure-u-1"> <div class="pure-u-1">
+ +
@@ -37,37 +34,36 @@
</div> </div>
</div> </div>
</div>
{% if add_server %} {% if add_server %}
<div id="content"> <div class="pure-u-1-3">
<div >
<div class="server-content-header pure-g"> <div class="server-content-header pure-g">
<div > <div class="pure-u-1-2">
<h1 class="server-content-title">Add new server</h1> <h1 class="server-content-title">Add new server</h1>
</div> </div>
</div> </div>
<div class="server-content-body"> <div class="server-content-body">
<form action="/add_server" class="pure-form pure-form-stacked" method="POST"> <form action="/add_server" class="pure-form pure-form-stacked" method="POST">
<fieldset> <fieldset>
<div > <div class="pure-g">
<label for="url">Server management URL</label> <div class="pure-u-1 pure-u-md-1-3">
<input type="text" class="pure-u-1" name="url" placeholder="https://example.com:5743/KSsdywe6Sdb..."/> <input type="text" class="pure-u-23-24" name="url" placeholder="Server management URL"/>
<label for="cert">Server management Certificate</label> </div>
<input type="text" class="pure-u-1" name="cert" placeholder="B5DD2443DAF..."/> <div class="pure-u-1 pure-u-md-1-3">
<label for="cert">Server Comment <input type="text"class="pure-u-23-24" name="cert" placeholder="Certificate"/>
<span class="pure-form-message">This will be exposed to client and will be used as "Server name" in client Outline app</span> </div>
</label> <div class="pure-u-1 pure-u-md-1-3">
<input type="text" class="pure-u-1" name="comment" placeholder="e.g. server location"/> <input type="text" class="pure-u-23-24" name="comment" placeholder="Comment"/>
</div>
</div> </div>
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button> <button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
</fieldset> </fieldset>
</form> </form>
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}
{% if SERVERS|length != 0 and not add_server %} {% if SERVERS|length != 0 and not add_server %}
{% if selected_server is none %} {% if selected_server is none %}
@@ -75,12 +71,11 @@
{% else %} {% else %}
{% set server = SERVERS[selected_server|int] %} {% set server = SERVERS[selected_server|int] %}
{% endif %} {% endif %}
<div id="content"> <div id="main" class="pure-u-1">
<div >
<div class="server-content"> <div class="server-content">
<div class="server-content-header pure-g"> <div class="server-content-header pure-g">
<div> <div class="pure-u-1-2">
<h1 class="content-title">{{server.info()["name"]}}</h1> <h1 class="server-content-title">{{server.info()["name"]}}</h1>
<p class="server-content-subtitle"> <p class="server-content-subtitle">
<span>v.{{server.info()["version"]}} {{server.info()["local_server_id"]}}</span> <span>v.{{server.info()["version"]}} {{server.info()["local_server_id"]}}</span>
</p> </p>
@@ -100,38 +95,35 @@
<form class="pure-form pure-form-stacked" method="POST"> <form class="pure-form pure-form-stacked" method="POST">
<fieldset> <fieldset>
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<label for="name">Server Name <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>
<span class="pure-form-message">This will not be exposed to client</span> <input type="text" id="name" class="pure-u-23-24" name="name" value="{{server.info()['name']}}"/>
</label>
<input type="text" id="name" class="pure-u-1" name="name" value="{{server.info()['name']}}"/>
</div> </div>
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<label for="comment">Comment</br> <label for="comment">Comment</br>This value will be used as "Server name" in client app.</label>
<span class="pure-form-message">This will be exposed to client and will be used as "Server name" in client Outline app</span></label> <input type="text" id="comment" class="pure-u-23-24" name="comment" value="{{server.info()['comment']}}"/>
<input type="text" id="comment" class="pure-u-1" name="comment" value="{{server.info()['comment']}}"/>
</div> </div>
<div class="pure-u-1"> <div class="pure-u-1 pure-u-md-1-3">
<label for="port_for_new_access_keys">Port For New Access Keys</label> <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-1" name="port_for_new_access_keys" value="{{server.info()['port_for_new_access_keys']}}"/> <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>
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<label for="hostname_for_access_keys">Hostname For Access Keys</label> <label for="hostname_for_access_keys">Hostname For Access Keys</label>
<input type="text" id="hostname_for_access_keys" class="pure-u-1" name="hostname_for_access_keys" value="{{server.info()['hostname_for_access_keys']}}"/> <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>
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<label for="url">Server URL</label> <label for="url">Server URL</label>
<input type="text" readonly id="url" class="pure-u-1" name="url" value="{{server.info()['url']}}"/> <input type="text" readonly id="url" class="pure-u-23-24" name="url" value="{{server.info()['url']}}"/>
</div> </div>
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<label for="cert">Server Access Certificate</label> <label for="cert">Server Access Certificate</label>
<input type="text" readonly id="cert" class="pure-u-1" name="cert" value="{{server.info()['cert']}}"/> <input type="text" readonly id="cert" class="pure-u-23-24" name="cert" value="{{server.info()['cert']}}"/>
</div> </div>
<div class="pure-u-1 "> <div class="pure-u-1 pure-u-md-1-3">
<label for="created_timestamp_ms">Created</label> <label for="created_timestamp_ms">Created</label>
<input type="text" readonly id="created_timestamp_ms" class="pure-u-1" name="created_timestamp_ms" value="{{format_timestamp(server.info()['created_timestamp_ms']) }}"/> <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> </div>
<input type="hidden" readonly id="server_id" name="server_id" value="{{server.info()['local_server_id']}}"/> <input type="hidden" readonly id="server_id" class="pure-u-23-24" name="server_id" value="{{server.info()['local_server_id']}}"/>
</div> </div>
<p>Share anonymous metrics</p> <p>Share anonymous metrics</p>
<label for="metrics_enabled" class="pure-radio"> <label for="metrics_enabled" class="pure-radio">
@@ -152,7 +144,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}

View File

@@ -1,83 +0,0 @@
$url = Read-Host "Please enter the URL for the JSON configuration"
$comment = Read-Host "Comment [server, country, etc]"
$port = Read-Host "Please enter the port to use for sslocal"
$version = "1.21.0"
$archiveUrl = "https://github.com/shadowsocks/shadowsocks-rust/releases/download/v${version}/shadowsocks-v${version}.x86_64-pc-windows-gnu.zip"
$downloadPath = "$HOME\shadowsocks-rust\shadowsocks.zip"
$extractPath = "$HOME\shadowsocks-rust"
$scriptUrl = "https://raw.githubusercontent.com/house-of-vanity/OutFleet/refs/heads/master/tools/windows_task.ps1"
$cmdFilePath = "$extractPath\run_${comment}.cmd"
$taskName = "Shadowsocks_Task_${comment}"
$logFile = "$extractPath\Log_${comment}.log"
if ($url -notmatch "^[a-z]+://") {
$url = "https://$url"
} elseif ($url -like "ssconf://*") {
$url = $url -replace "^ssconf://", "https://"
}
function Test-Admin {
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-Not (Test-Admin)) {
Write-Host "Error: This script requires administrator privileges. Please run PowerShell as administrator." -ForegroundColor Red
exit 1
}
# Ensure the extraction directory exists
if (-Not (Test-Path -Path $extractPath)) {
New-Item -ItemType Directory -Path $extractPath
}
# Download the archive
Invoke-WebRequest -Uri $archiveUrl -OutFile $downloadPath
# Extract the archive
Expand-Archive -Path $downloadPath -DestinationPath $extractPath -Force
# Check if sslocal.exe exists
if (-Not (Test-Path -Path "$extractPath\sslocal.exe")) {
Write-Host "Error: sslocal.exe not found in $extractPath" -ForegroundColor Red
exit 1
}
# Download the windows_task.ps1 script
Invoke-WebRequest -Uri $scriptUrl -OutFile "$extractPath\windows_task.ps1"
# Build Batch file content
$batchContent = @"
@echo off
set scriptPath=""$extractPath\windows_task.ps1""
powershell.exe -ExecutionPolicy Bypass -File %scriptPath% ""$url"" ""$extractPath\sslocal.exe"" ""$port""
"@
$batchContent | Set-Content -Path $cmdFilePath
# Create or update Task Scheduler
$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument "/c $cmdFilePath > $logFile"
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
# Check if the task already exists
$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($existingTask) {
Write-Host "Task $taskName already exists. Updating the task..."
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}
# Register the new or updated task
Register-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings -TaskName $taskName
Write-Host "Task $taskName has been created/updated successfully."
# Optionally, start the task immediately
Start-ScheduledTask -TaskName $taskName
Write-Host "Task $taskName has been started."

View File

@@ -1,100 +0,0 @@
if ($args.Count -lt 3) {
Write-Host "Usage: windows_task.ps1 <url> <sslocal_path> <local_port>"
exit 1
}
$url = $args[0]
$sslocalPath = $args[1]
$localPort = $args[2]
$localAddr = "localhost"
$checkInterval = 60
$previousPassword = ""
# Function to get the process ID of the process listening on a specific port
function Get-ProcessByPort {
param (
[int]$port
)
# Use Get-NetTCPConnection to find the process listening on the given port
$connection = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Listen' }
if ($connection) {
# Get the owning process ID (OwningProcess) from the connection
$pid = $connection.OwningProcess
return Get-Process -Id $pid -ErrorAction SilentlyContinue
} else {
return $null
}
}
# Function to start sslocal
function Start-SSLocal {
param (
[string]$method,
[string]$password,
[string]$server,
[int]$serverPort
)
# Form the Shadowsocks connection string
$credentials = "${method}:${password}@${server}:${serverPort}"
$encodedCredentials = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($credentials))
$ssUrl = "ss://$encodedCredentials"
# Get the process listening on the specified port and kill it if found
$process = Get-ProcessByPort -port $localPort
if ($process) {
Write-Host "Killing process $($process.Id) using port $localPort"
Stop-Process -Id $process.Id -Force
}
# Log the sslocal restart
Write-Host "Starting sslocal with method: $method, server: $server, port: $serverPort"
# Start sslocal with the provided arguments
Start-Process -NoNewWindow -FilePath $sslocalPath -ArgumentList "--local-addr ${localAddr}:${localPort} --server-url $ssUrl"
}
# Main loop
while ($true) {
try {
if ($url -notmatch "mode=json") {
$delimiter = "?"
if ($url -match "\?") {
$delimiter = "&"
}
$url = "$url${delimiter}mode=json"
}
# Download and parse the JSON
$jsonContent = Invoke-WebRequest -Uri $url -UseBasicParsing | Select-Object -ExpandProperty Content
$json = $jsonContent | ConvertFrom-Json
# Extract the necessary fields
$method = $json.method
$password = $json.password
$server = $json.server
$serverPort = $json.server_port
# Log current password and server information
Write-Host "Checking server: $server, port: $serverPort"
# Check if the password has changed
if ($password -ne $previousPassword) {
# Start/restart sslocal
Start-SSLocal -method $method -password $password -server $server -serverPort $serverPort
$previousPassword = $password
Write-Host "Password changed, restarting sslocal."
} else {
Write-Host "Password has not changed."
}
} catch {
Write-Host "Error occurred: $_"
}
# Wait for the next check
Start-Sleep -Seconds $checkInterval
}