mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-07-06 09:04:07 +00:00
Django UI
This commit is contained in:
13
.gitignore
vendored
13
.gitignore
vendored
@ -1,9 +1,8 @@
|
||||
config.yaml
|
||||
__pycache__/
|
||||
sync.log
|
||||
main.py
|
||||
.idea/*
|
||||
.vscode/*
|
||||
db.sqlite3
|
||||
debug.log
|
||||
*.swp
|
||||
*.swo
|
||||
*.swn
|
||||
*.pyc
|
||||
staticfiles/
|
||||
*.__pycache__.*
|
||||
vpn/migrations/
|
13
Dockerfile
Executable file → Normal file
13
Dockerfile
Executable file → Normal file
@ -1,13 +1,10 @@
|
||||
FROM python:3-alpine
|
||||
FROM python:3
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY static static
|
||||
COPY templates templates
|
||||
COPY *.py .
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["python", "main.py"]
|
||||
COPY . .
|
||||
|
||||
CMD [ "python", "./manage.py", "runserver", "0.0.0.0:8000" ]
|
||||
|
BIN
img/servers.png
BIN
img/servers.png
Binary file not shown.
Before Width: | Height: | Size: 111 KiB |
127
k8s.py
127
k8s.py
@ -1,127 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
import yaml
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
import lib
|
||||
from kubernetes import client, config as kube_config
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
log = logging.getLogger("OutFleet.k8s")
|
||||
|
||||
NAMESPACE = False
|
||||
SERVERS = list()
|
||||
CONFIG = None
|
||||
V1 = None
|
||||
K8S_DETECTED = False
|
||||
|
||||
|
||||
def discovery_servers():
|
||||
global CONFIG
|
||||
interval = 60
|
||||
log = logging.getLogger("OutFleet.discovery")
|
||||
|
||||
if K8S_DETECTED:
|
||||
while True:
|
||||
pods = V1.list_namespaced_pod(NAMESPACE, label_selector="app=shadowbox")
|
||||
log.debug(f"Started discovery thread every {interval}")
|
||||
for pod in pods.items:
|
||||
log.debug(f"Found Outline server pod {pod.metadata.name}")
|
||||
container_log = V1.read_namespaced_pod_log(name=pod.metadata.name, namespace=NAMESPACE, container='manager-config-json')
|
||||
secret = json.loads(container_log.replace('\'', '\"'))
|
||||
config = lib.get_config()
|
||||
config_servers = find_server(secret, config["servers"])
|
||||
#log.info(f"config_servers {config_servers}")
|
||||
if len(config_servers) > 0:
|
||||
log.debug(f"Already exist")
|
||||
pass
|
||||
else:
|
||||
with lib.lock:
|
||||
config["servers"][str(uuid.uuid4())] = {
|
||||
"cert": secret["certSha256"],
|
||||
"name": f"{pod.metadata.name}",
|
||||
"comment": f"{pod.spec.node_name}",
|
||||
"url": secret["apiUrl"],
|
||||
}
|
||||
write_config(config)
|
||||
log.info(f"Added discovered server")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def find_server(search_data, servers):
|
||||
found_servers = {}
|
||||
for server_id, server_info in servers.items():
|
||||
if server_info["url"] == search_data["apiUrl"] and server_info["cert"] == search_data["certSha256"]:
|
||||
found_servers[server_id] = server_info
|
||||
return found_servers
|
||||
|
||||
|
||||
|
||||
def write_config(config):
|
||||
config_map = client.V1ConfigMap(
|
||||
api_version="v1",
|
||||
kind="ConfigMap",
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=f"config-outfleet",
|
||||
labels={
|
||||
"app": "outfleet",
|
||||
}
|
||||
),
|
||||
data={"config.yaml": yaml.dump(config)}
|
||||
)
|
||||
try:
|
||||
api_response = V1.create_namespaced_config_map(
|
||||
namespace=NAMESPACE,
|
||||
body=config_map,
|
||||
)
|
||||
except ApiException as e:
|
||||
api_response = V1.patch_namespaced_config_map(
|
||||
name="config-outfleet",
|
||||
namespace=NAMESPACE,
|
||||
body=config_map,
|
||||
)
|
||||
log.info("Updated config in Kubernetes ConfigMap [config-outfleet]")
|
||||
|
||||
|
||||
def reload_config():
|
||||
global CONFIG
|
||||
while True:
|
||||
with lib.lock:
|
||||
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.debug(f"Synced system config with ConfigMap [config-outfleet].")
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
try:
|
||||
kube_config.load_incluster_config()
|
||||
V1 = client.CoreV1Api()
|
||||
if V1 != None:
|
||||
K8S_DETECTED = True
|
||||
try:
|
||||
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f:
|
||||
NAMESPACE = f.read().strip()
|
||||
log.info(f"Found Kubernetes environment. Deployed to namespace '{NAMESPACE}'")
|
||||
try:
|
||||
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.info(f"ConfigMap loaded from Kubernetes API. Servers: {len(CONFIG['servers'])}, Clients: {len(CONFIG['clients'])}. Started monitoring for changes every minute.")
|
||||
except Exception as e:
|
||||
try:
|
||||
write_config({"clients": [], "servers": {}, "ui_hostname": "accessible-address.com"})
|
||||
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.info("Created new ConfigMap [config-outfleet]. Started monitoring for changes every minute.")
|
||||
except Exception as e:
|
||||
log.info(f"Failed to create new ConfigMap [config-outfleet] {e}")
|
||||
thread = threading.Thread(target=reload_config)
|
||||
thread.start()
|
||||
|
||||
except:
|
||||
log.info("Kubernetes environment not detected")
|
||||
except:
|
||||
log.info("Kubernetes environment not detected")
|
||||
|
184
lib.py
184
lib.py
@ -1,184 +0,0 @@
|
||||
import argparse
|
||||
import logging
|
||||
import threading
|
||||
from typing import TypedDict, List
|
||||
from outline_vpn.outline_vpn import OutlineKey, OutlineVPN
|
||||
import yaml
|
||||
import k8s
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%d-%m-%Y %H:%M:%S",
|
||||
)
|
||||
|
||||
log = logging.getLogger(f'OutFleet.lib')
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
default="/usr/local/etc/outfleet/config.yaml",
|
||||
help="Config file location",
|
||||
)
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
def get_config():
|
||||
if k8s.CONFIG:
|
||||
return k8s.CONFIG
|
||||
else:
|
||||
try:
|
||||
with open(args.config, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
if config == None:
|
||||
config = {
|
||||
"servers": {},
|
||||
"clients": {}
|
||||
}
|
||||
except:
|
||||
try:
|
||||
with open(args.config, "w"):
|
||||
config = {
|
||||
"servers": {},
|
||||
"clients": {}
|
||||
}
|
||||
yaml.safe_dump(config, file)
|
||||
except Exception as exp:
|
||||
log.error(f"Couldn't create config. {exp}")
|
||||
return None
|
||||
return config
|
||||
|
||||
def write_config(config):
|
||||
if k8s.CONFIG:
|
||||
k8s.write_config(config)
|
||||
else:
|
||||
try:
|
||||
with open(args.config, "w") as file:
|
||||
yaml.safe_dump(config, file)
|
||||
except Exception as e:
|
||||
log.error(f"Couldn't write Outfleet config: {e}")
|
||||
|
||||
|
||||
class ServerDict(TypedDict):
|
||||
server_id: str
|
||||
local_server_id: str
|
||||
name: str
|
||||
url: str
|
||||
cert: str
|
||||
comment: 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,
|
||||
# read from config. not the same as real server id you can get from api
|
||||
local_server_id: str,
|
||||
):
|
||||
self.client = OutlineVPN(api_url=url, cert_sha256=cert)
|
||||
self.data: ServerDict = {
|
||||
"local_server_id": local_server_id,
|
||||
"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(),
|
||||
}
|
||||
self.log = logging.getLogger(f'OutFleet.server[{self.data["name"]}]')
|
||||
|
||||
def info(self) -> ServerDict:
|
||||
return self.data
|
||||
|
||||
def check_client(self, name):
|
||||
# Looking for any users with provided name. len(result) != 1 is a problem.
|
||||
result = []
|
||||
for key in self.client.get_keys():
|
||||
if key.name == name:
|
||||
result.append(name)
|
||||
self.log.info(f"check_client found client `{name}` config is correct.")
|
||||
if len(result) != 1:
|
||||
self.log.warning(
|
||||
f"check_client found client `{name}` inconsistent. Found {len(result)} keys."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def apply_config(self, config):
|
||||
if config.get("name"):
|
||||
self.client.set_server_name(config.get("name"))
|
||||
self.log.info(
|
||||
"Changed %s name to '%s'", self.data["local_server_id"], config.get("name")
|
||||
)
|
||||
if config.get("metrics"):
|
||||
self.client.set_metrics_status(
|
||||
True if config.get("metrics") == "True" else False
|
||||
)
|
||||
self.log.info(
|
||||
"Changed %s metrics status to '%s'",
|
||||
self.data["local_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"))
|
||||
)
|
||||
self.log.info(
|
||||
"Changed %s port_for_new_access_keys to '%s'",
|
||||
self.data["local_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"))
|
||||
self.log.info(
|
||||
"Changed %s hostname_for_access_keys to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("hostname_for_access_keys"),
|
||||
)
|
||||
if config.get("comment"):
|
||||
config_file = get_config()
|
||||
config_file["servers"][self.data["local_server_id"]]["comment"] = config.get(
|
||||
"comment"
|
||||
)
|
||||
write_config(config_file)
|
||||
self.log.info(
|
||||
"Changed %s comment to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("comment"),
|
||||
)
|
||||
|
||||
def create_key(self, key_name):
|
||||
self.client.create_key(key_id=key_name, name=key_name)
|
||||
self.log.info("New key created: %s", key_name)
|
||||
return True
|
||||
|
||||
def rename_key(self, key_id, new_name):
|
||||
self.log.info("Key %s renamed: %s", key_id, new_name)
|
||||
return self.client.rename_key(key_id, new_name)
|
||||
|
||||
def delete_key(self, key_id):
|
||||
self.log.info("Key %s deleted", key_id)
|
||||
return self.client.delete_key(key_id)
|
480
main.py
480
main.py
@ -1,480 +0,0 @@
|
||||
import threading
|
||||
import time
|
||||
import yaml
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import random
|
||||
import string
|
||||
import argparse
|
||||
import uuid
|
||||
import bcrypt
|
||||
|
||||
|
||||
import k8s
|
||||
from flask import Flask, render_template, request, url_for, redirect
|
||||
from flask_cors import CORS
|
||||
from werkzeug.routing import BaseConverter
|
||||
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.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%d-%m-%Y %H:%M:%S",
|
||||
)
|
||||
|
||||
log = logging.getLogger("OutFleet")
|
||||
file_handler = logging.FileHandler("sync.log")
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
log.addHandler(file_handler)
|
||||
duplicate_filter = DuplicateFilter()
|
||||
log.addFilter(duplicate_filter)
|
||||
|
||||
CFG_PATH = args.config
|
||||
NAMESPACE = k8s.NAMESPACE
|
||||
SERVERS = list()
|
||||
BROKEN_SERVERS = list()
|
||||
CLIENTS = dict()
|
||||
VERSION = '8.1'
|
||||
SECRET_LINK_LENGTH = 8
|
||||
SECRET_LINK_PREFIX = '$2b$12$'
|
||||
SS_PREFIX = "\u0005\u00DC\u005F\u00E0\u0001\u0020"
|
||||
HOSTNAME = ""
|
||||
WRONG_DOOR = "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
|
||||
def format_timestamp(ts):
|
||||
return datetime.fromtimestamp(ts // 1000).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def random_string(length=64):
|
||||
letters = string.ascii_letters + string.digits
|
||||
|
||||
return "".join(random.choice(letters) for i in range(length))
|
||||
|
||||
|
||||
def update_state(timer=40):
|
||||
while True:
|
||||
with lock:
|
||||
global SERVERS
|
||||
global CLIENTS
|
||||
global BROKEN_SERVERS
|
||||
global HOSTNAME
|
||||
config = get_config()
|
||||
|
||||
if config:
|
||||
HOSTNAME = config.get("ui_hostname", "my-own-SSL-ENABLED-domain.com")
|
||||
servers = config.get("servers", dict())
|
||||
_SERVERS = list()
|
||||
for local_server_id, server_config in servers.items():
|
||||
try:
|
||||
server = Server(
|
||||
url=server_config["url"],
|
||||
cert=server_config["cert"],
|
||||
comment=server_config.get("comment", ''),
|
||||
local_server_id=local_server_id,
|
||||
)
|
||||
_SERVERS.append(server)
|
||||
log.debug(
|
||||
"Server state updated: %s, [%s]",
|
||||
server.info()["name"],
|
||||
local_server_id,
|
||||
)
|
||||
except Exception as e:
|
||||
BROKEN_SERVERS.append({
|
||||
"config": server_config,
|
||||
"error": e,
|
||||
"id": local_server_id
|
||||
})
|
||||
log.warning("Can't access server: %s - %s", server_config["url"], e)
|
||||
SERVERS = _SERVERS
|
||||
CLIENTS = config.get("clients", dict())
|
||||
if timer == 0:
|
||||
break
|
||||
time.sleep(40)
|
||||
|
||||
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def index():
|
||||
if request.method == "GET":
|
||||
#if request.args.get("broken") == True:
|
||||
return render_template(
|
||||
"index.html",
|
||||
SERVERS=SERVERS,
|
||||
VERSION=VERSION,
|
||||
K8S_NAMESPACE=k8s.NAMESPACE,
|
||||
BROKEN_SERVERS=BROKEN_SERVERS,
|
||||
nt=request.args.get("nt"),
|
||||
nl=request.args.get("nl"),
|
||||
selected_server=request.args.get("selected_server"),
|
||||
broken=request.args.get("broken", False),
|
||||
add_server=request.args.get("add_server", None),
|
||||
format_timestamp=format_timestamp,
|
||||
)
|
||||
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"])
|
||||
def clients():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"clients.html",
|
||||
SERVERS=SERVERS,
|
||||
bcrypt=bcrypt,
|
||||
CLIENTS=CLIENTS,
|
||||
VERSION=VERSION,
|
||||
SECRET_LINK_LENGTH=SECRET_LINK_LENGTH,
|
||||
SECRET_LINK_PREFIX=SECRET_LINK_PREFIX,
|
||||
K8S_NAMESPACE=k8s.NAMESPACE,
|
||||
nt=request.args.get("nt"),
|
||||
nl=request.args.get("nl"),
|
||||
selected_client=request.args.get("selected_client"),
|
||||
add_client=request.args.get("add_client", None),
|
||||
format_timestamp=format_timestamp,
|
||||
dynamic_hostname=HOSTNAME,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/add_server", methods=["POST"])
|
||||
def add_server():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
config = get_config()
|
||||
servers = config.get("servers", dict())
|
||||
local_server_id = str(uuid.uuid4())
|
||||
|
||||
new_server = Server(
|
||||
url=request.form["url"],
|
||||
cert=request.form["cert"],
|
||||
comment=request.form["comment"],
|
||||
local_server_id=local_server_id,
|
||||
)
|
||||
|
||||
servers[new_server.data["local_server_id"]] = {
|
||||
"name": new_server.data["name"],
|
||||
"url": new_server.data["url"],
|
||||
"comment": new_server.data["comment"],
|
||||
"cert": request.form["cert"],
|
||||
}
|
||||
config["servers"] = servers
|
||||
write_config(config)
|
||||
log.info("Added server: %s", new_server.data["name"])
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("index", nt="Added Outline VPN Server"))
|
||||
except Exception as e:
|
||||
return redirect(
|
||||
url_for(
|
||||
"index", nt=f"Couldn't access Outline VPN Server: {e}", nl="error"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.route("/del_server", methods=["POST"])
|
||||
def del_server():
|
||||
if request.method == "POST":
|
||||
config = get_config()
|
||||
|
||||
local_server_id = request.form.get("local_server_id")
|
||||
server_name = None
|
||||
try:
|
||||
server_name = config["servers"].pop(local_server_id)["name"]
|
||||
except KeyError as e:
|
||||
pass
|
||||
for client_id, client_config in config["clients"].items():
|
||||
try:
|
||||
client_config["servers"].remove(local_server_id)
|
||||
except ValueError as e:
|
||||
pass
|
||||
write_config(config)
|
||||
log.info("Deleting server %s [%s]", server_name, request.form.get("local_server_id"))
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("index", nt=f"Server {server_name} has been deleted"))
|
||||
|
||||
|
||||
@app.route("/add_client", methods=["POST"])
|
||||
def add_client():
|
||||
if request.method == "POST":
|
||||
config = get_config()
|
||||
|
||||
clients = config.get("clients", dict())
|
||||
user_id = request.form.get("user_id", random_string())
|
||||
|
||||
clients[user_id] = {
|
||||
"name": request.form.get("name"),
|
||||
"comment": request.form.get("comment"),
|
||||
"servers": request.form.getlist("servers"),
|
||||
}
|
||||
config["clients"] = clients
|
||||
write_config(config)
|
||||
log.info("Client %s updated", request.form.get("name"))
|
||||
|
||||
for server in SERVERS:
|
||||
if server.data["local_server_id"] in request.form.getlist("servers"):
|
||||
client = next(
|
||||
(
|
||||
item
|
||||
for item in server.data["keys"]
|
||||
if item.name == request.form.get("old_name")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
if client.name == request.form.get("name"):
|
||||
pass
|
||||
else:
|
||||
server.rename_key(client.key_id, request.form.get("name"))
|
||||
log.info(
|
||||
"Renaming key %s to %s on server %s",
|
||||
request.form.get("old_name"),
|
||||
request.form.get("name"),
|
||||
server.data["name"],
|
||||
)
|
||||
else:
|
||||
server.create_key(request.form.get("name"))
|
||||
log.info(
|
||||
"Creating key %s on server %s",
|
||||
request.form.get("name"),
|
||||
server.data["name"],
|
||||
)
|
||||
else:
|
||||
client = next(
|
||||
(
|
||||
item
|
||||
for item in server.data["keys"]
|
||||
if item.name == request.form.get("old_name")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
server.delete_key(client.key_id)
|
||||
log.info(
|
||||
"Deleting key %s on server %s",
|
||||
request.form.get("name"),
|
||||
server.data["name"],
|
||||
)
|
||||
update_state(timer=0)
|
||||
return redirect(
|
||||
url_for(
|
||||
"clients",
|
||||
nt="Clients updated",
|
||||
selected_client=request.form.get("user_id"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
return redirect(url_for("clients"))
|
||||
|
||||
|
||||
@app.route("/del_client", methods=["POST"])
|
||||
def del_client():
|
||||
if request.method == "POST":
|
||||
config = get_config()
|
||||
clients = config.get("clients", dict())
|
||||
user_id = request.form.get("user_id")
|
||||
if user_id in clients:
|
||||
for server in SERVERS:
|
||||
client = next(
|
||||
(
|
||||
item
|
||||
for item in server.data["keys"]
|
||||
if item.name == request.form.get("name")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
server.delete_key(client.key_id)
|
||||
|
||||
config["clients"].pop(user_id)
|
||||
write_config(config)
|
||||
log.info("Deleting client %s", request.form.get("name"))
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("clients", nt="User has been deleted"))
|
||||
|
||||
|
||||
@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)
|
||||
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:
|
||||
|
||||
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):
|
||||
try:
|
||||
client = next(
|
||||
(keys for client, keys in CLIENTS.items() if client == client_id), None
|
||||
)
|
||||
server = next(
|
||||
(item for item in SERVERS if item.info()["name"] == server_name), None
|
||||
)
|
||||
key = next(
|
||||
(item for item in server.data["keys"] if item.key_id == client["name"]), None
|
||||
)
|
||||
if server and client and key:
|
||||
if server.data["local_server_id"] in client["servers"]:
|
||||
log.info(
|
||||
"Client %s has been requested ssconf for %s", client["name"], server.data["name"]
|
||||
)
|
||||
return {
|
||||
"server": server.data["hostname_for_access_keys"],
|
||||
"server_port": key.port,
|
||||
"password": key.password,
|
||||
"method": key.method,
|
||||
"prefix":SS_PREFIX,
|
||||
"info": "Managed by OutFleet [github.com/house-of-vanity/OutFleet/]",
|
||||
}
|
||||
else:
|
||||
log.warning(
|
||||
"Hack attempt! Client %s denied by ACL on %s",
|
||||
client["name"],
|
||||
server.data["name"],
|
||||
)
|
||||
return WRONG_DOOR
|
||||
except:
|
||||
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
|
||||
return WRONG_DOOR
|
||||
|
||||
|
||||
|
||||
@app.route("/dynamic", methods=["GET"], strict_slashes=False)
|
||||
def _dynamic():
|
||||
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
|
||||
return WRONG_DOOR
|
||||
|
||||
|
||||
@app.route("/sync", methods=["GET", "POST"])
|
||||
def sync():
|
||||
if request.method == "GET":
|
||||
try:
|
||||
with open("sync.log", "r") as file:
|
||||
lines = file.readlines()
|
||||
except:
|
||||
lines = []
|
||||
return render_template(
|
||||
"sync.html",
|
||||
SERVERS=SERVERS,
|
||||
CLIENTS=CLIENTS,
|
||||
lines=lines,
|
||||
)
|
||||
if request.method == "POST":
|
||||
with lock:
|
||||
if request.form.get("wipe") == 'all':
|
||||
for server in SERVERS:
|
||||
log.info("Wiping all keys on [%s]", server.data["name"])
|
||||
for client in server.data['keys']:
|
||||
server.delete_key(client.key_id)
|
||||
|
||||
server_hash = {}
|
||||
with lock:
|
||||
for server in SERVERS:
|
||||
server_hash[server.data["local_server_id"]] = server
|
||||
with lock:
|
||||
for key, client in CLIENTS.items():
|
||||
for u_server_id in client["servers"]:
|
||||
if u_server_id in server_hash:
|
||||
if not server_hash[u_server_id].check_client(client["name"]):
|
||||
log.warning(
|
||||
f"Client {client['name']} absent on {server_hash[u_server_id].data['name']}"
|
||||
)
|
||||
server_hash[u_server_id].create_key(client["name"])
|
||||
else:
|
||||
log.info(
|
||||
f"Client {client['name']} already present on {server_hash[u_server_id].data['name']}"
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
f"Client {client['name']} incorrect server_id {u_server_id}"
|
||||
)
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("sync"))
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_state_thread = threading.Thread(target=update_state)
|
||||
update_state_thread.start()
|
||||
|
||||
discovery_servers_thread = threading.Thread(target=k8s.discovery_servers)
|
||||
discovery_servers_thread.start()
|
||||
app.run(host="0.0.0.0")
|
22
manage.py
Executable file
22
manage.py
Executable file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
3
mysite/__init__.py
Normal file
3
mysite/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
16
mysite/asgi.py
Normal file
16
mysite/asgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for mysite project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
|
||||
application = get_asgi_application()
|
21
mysite/celery.py
Normal file
21
mysite/celery.py
Normal file
@ -0,0 +1,21 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
logger = logging.getLogger(__name__)
|
||||
app = Celery('mysite')
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
|
14
mysite/middleware.py
Normal file
14
mysite/middleware.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django.urls import resolve
|
||||
from django.http import Http404, HttpResponseNotFound
|
||||
|
||||
class RequestLogger:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
print(f"Original: {request.build_absolute_uri()}")
|
||||
print(f"Path : {request.path}")
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
return response
|
208
mysite/settings.py
Normal file
208
mysite/settings.py
Normal file
@ -0,0 +1,208 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import environ
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
ENV = environ.Env(
|
||||
DEBUG=(bool, False)
|
||||
)
|
||||
|
||||
environ.Env.read_env()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY=ENV('SECRET_KEY', default=get_random_secret_key())
|
||||
TIME_ZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
||||
|
||||
CELERY_BROKER_URL = ENV('CELERY_BROKER_URL', default='redis://localhost:6379/0')
|
||||
CELERY_RESULT_BACKEND = 'django-db'
|
||||
CELERY_TIMEZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_RESULT_EXTENDED = True
|
||||
|
||||
# CACHES = {
|
||||
# 'default': {
|
||||
# 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
|
||||
# 'LOCATION': 'cache_table',
|
||||
# }
|
||||
# }
|
||||
|
||||
DEBUG = ENV('DEBUG')
|
||||
|
||||
ALLOWED_HOSTS = ENV.list('ALLOWED_HOSTS', default=["*"])
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CSRF_TRUSTED_ORIGINS = ENV.list('CSRF_TRUSTED_ORIGINS', default=[])
|
||||
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '[{asctime}] {levelname} {name} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
'simple': {
|
||||
'format': '{levelname} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'file': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': os.path.join(BASE_DIR, 'debug.log'),
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
'vpn': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
'requests': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'urllib3': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'jazzmin',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'polymorphic',
|
||||
'corsheaders',
|
||||
'django_celery_results',
|
||||
'vpn',
|
||||
]
|
||||
|
||||
|
||||
|
||||
MIDDLEWARE = [
|
||||
#'mysite.middleware.RequestLogger',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'mysite.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [os.path.join(BASE_DIR, 'vpn', 'templates')],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'mysite.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
# CREATE USER outfleet WITH PASSWORD 'password';
|
||||
# GRANT ALL PRIVILEGES ON DATABASE outfleet TO outfleet;
|
||||
# ALTER DATABASE outfleet OWNER TO outfleet;
|
||||
|
||||
DATABASES = {
|
||||
'sqlite': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
},
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': ENV('POSTGRES_DB', default="outfleet"),
|
||||
'USER': ENV('POSTGRES_USER', default="outfleet"),
|
||||
'PASSWORD': ENV('POSTGRES_PASSWORD', default="password"),
|
||||
'HOST': ENV('POSTGRES_HOST', default='localhost'),
|
||||
'PORT': ENV('POSTGRES_PORT', default='5432'),
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
27
mysite/urls.py
Normal file
27
mysite/urls.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""
|
||||
URL configuration for mysite project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.1/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.views.generic import RedirectView
|
||||
from vpn.views import shadowsocks
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('ss/<str:link>', shadowsocks, name='shadowsocks'),
|
||||
path('dynamic/<str:link>', shadowsocks, name='shadowsocks'),
|
||||
path('', RedirectView.as_view(url='/admin/', permanent=False)),
|
||||
]
|
16
mysite/wsgi.py
Normal file
16
mysite/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for mysite project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
|
||||
application = get_wsgi_application()
|
20
requirements.txt
Executable file → Normal file
20
requirements.txt
Executable file → Normal file
@ -1,6 +1,14 @@
|
||||
outline-vpn-api
|
||||
kubernetes
|
||||
PyYAML>=6.0.1
|
||||
Flask>=2.3.3
|
||||
flask-cors
|
||||
bcrypt
|
||||
django-environ==0.11.2
|
||||
Django==5.1.2
|
||||
celery==5.4.0
|
||||
django-jazzmin==3.0.1
|
||||
django-polymorphic==3.1.0
|
||||
django-cors-headers==4.5.0
|
||||
requests==2.32.3
|
||||
outline-vpn-api==6.3.0
|
||||
Redis==5.1.1
|
||||
whitenoise==6.7.0
|
||||
psycopg2-binary==2.9.10
|
||||
setuptools==75.2.0
|
||||
shortuuid==1.0.13
|
||||
django-celery-results==2.5.1
|
21
static/admin/js/generate_uuid.js
Normal file
21
static/admin/js/generate_uuid.js
Normal file
@ -0,0 +1,21 @@
|
||||
// static/admin/js/generate_uuid.js
|
||||
|
||||
function generateUUID() {
|
||||
let uuid = '';
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const length = 10;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * characters.length);
|
||||
uuid += characters[randomIndex];
|
||||
}
|
||||
|
||||
const hashField = document.getElementById('id_hash');
|
||||
if (hashField) {
|
||||
hashField.value = uuid;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
generateUUID();
|
||||
});
|
@ -1,475 +0,0 @@
|
||||
:root {
|
||||
--app-h: 100vh;
|
||||
--app-space-1: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* -- BASE STYLES --
|
||||
* Most of these are inherited from Base, but I want to change a few.
|
||||
*/
|
||||
body {
|
||||
color: #333;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #1b98f8;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* -- HELPER STYLES --
|
||||
* Over-riding some of the .pure-button styles to make my buttons look unique
|
||||
*/
|
||||
.button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
background: #9d2c2c;
|
||||
border: 1px solid #480b0b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/*
|
||||
* -- 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`
|
||||
*/
|
||||
#nav,
|
||||
#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: 100%;
|
||||
}
|
||||
|
||||
/* 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-content-title {
|
||||
padding: var(--app-space-1);
|
||||
/* color: #ffffff; */
|
||||
}
|
||||
|
||||
.server-item {
|
||||
padding: var(--app-space-1);
|
||||
/* color: #ffffff; */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-item:hover {
|
||||
background: #d1d0d0;
|
||||
}
|
||||
|
||||
.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: #eeeeee;
|
||||
}
|
||||
|
||||
.server-item-unread {
|
||||
border-left: 6px solid #1b98f8;
|
||||
}
|
||||
|
||||
.server-item-broken {
|
||||
border-left: 6px solid #880d06;
|
||||
}
|
||||
|
||||
|
||||
/* 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) {
|
||||
|
||||
/* These are position:fixed; elements that will be in the left 500px of the screen */
|
||||
#nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#nav {
|
||||
/* margin-left: -500px; */
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
#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 */
|
||||
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#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
File diff suppressed because one or more lines are too long
@ -1,167 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="entety-menu">
|
||||
<div id="list">
|
||||
<h1 class="server-content-title">Clients</h1>
|
||||
<form id="search-form" class="pure-form">
|
||||
<input placeholder="🔍" type="text" id="entety-menu-search" />
|
||||
</form>
|
||||
<div data-search-box class="srcollable-list-content">
|
||||
{% for client, values in CLIENTS.items() %}
|
||||
<div
|
||||
class="server-item server-item-{% if client == selected_client %}unread{% else %}selected{% endif %} pure-g">
|
||||
<div class="" onclick="location.href='/clients?selected_client={{ client }}';">
|
||||
<h5 data-search class="server-name">{{ values["name"] }}</h5>
|
||||
<h4 class="server-info">{{ values["servers"]|length }} server{% if values["servers"]|length >1
|
||||
%}s{%endif%}</h4>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div onclick="location.href='/clients?add_client=True';" class="server-item server-add pure-g">
|
||||
<div class="pure-u-1">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if add_client %}
|
||||
<div id="content">
|
||||
<div class="">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="">
|
||||
<h1 class="server-content-title">Add new client</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-content-body">
|
||||
<form action="/add_client" class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="" name="name" required placeholder="Name" />
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="" name="comment" placeholder="Comment" />
|
||||
</div>
|
||||
<div class="pure-checkbox">
|
||||
{% for server in SERVERS %}
|
||||
<div class="server-checkbox">
|
||||
<input type="checkbox" id="option{{loop.index0}}" name="servers"
|
||||
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 %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if selected_client and not add_client %}
|
||||
{% set client = CLIENTS[selected_client] %}
|
||||
|
||||
<div id="content">
|
||||
<div class="">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="">
|
||||
<h1 class="server-content-title">{{client['name']}}</h1>
|
||||
<h4 class="server-info">{{ client['comment'] }}</h4>
|
||||
<h4 class="server-info">id {{ selected_client }}</h4>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-content-body">
|
||||
<form action="/add_client" class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 ">
|
||||
<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']}}" />
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="pure-u-1" name="comment" value="{{client['comment']}}" />
|
||||
</div>
|
||||
<input type="hidden" class="pure-u-1" name="user_id" value="{{selected_client}}" />
|
||||
|
||||
<div class="pure-checkbox">
|
||||
|
||||
<p>Allow access to:</p>
|
||||
|
||||
{% for server in SERVERS %}
|
||||
<div class="server-checkbox">
|
||||
<input {% if server.info()['local_server_id'] in client['servers'] %}checked{%endif%}
|
||||
type="checkbox" id="option{{loop.index0}}" name="servers"
|
||||
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>
|
||||
<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 %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="pure-g pure-form pure-form-stacked">
|
||||
<div class="pure-u-1-2">
|
||||
<button type="submit" class="pure-button pure-button-primary button">Save and apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
</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>
|
||||
<h3>Invite text</h3>
|
||||
<hr>
|
||||
<p>Install Outline VPN. Copy and paste the keys below into the Outline client.
|
||||
The same keys can be used simultaneously on multiple devices.</p>
|
||||
{% for server in SERVERS -%}
|
||||
{% if server.info()['local_server_id'] in client['servers'] %}
|
||||
{% set salt = bcrypt.gensalt() %}
|
||||
{% set secret_string = server.info()['local_server_id'] + selected_client %}
|
||||
{% 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 %}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -1,159 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div id="entety-menu">
|
||||
<div>
|
||||
<h1 class="server-content-title">Servers</h1>
|
||||
<div class="srcollable-list-content">
|
||||
{% for server in SERVERS %}
|
||||
{% set total_traffic = namespace(total_bytes=0) %}
|
||||
{% for key in server.data["keys"] %}
|
||||
{% if key.used_bytes %}
|
||||
{% set total_traffic.total_bytes = total_traffic.total_bytes + key.used_bytes %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<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">Traffic: {{ total_traffic.total_bytes | filesizeformat }}</h4>
|
||||
<h4 class="server-info">v.{{ server.info()["version"] }}</h4>
|
||||
<p class="server-comment">
|
||||
{{ server.info()["comment"] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div onclick="location.href='/?add_server=True';" class="server-item server-add pure-g">
|
||||
<div class="pure-u-1">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if add_server %}
|
||||
<div id="content">
|
||||
<div >
|
||||
<div class="server-content-header pure-g">
|
||||
<div >
|
||||
<h1 class="server-content-title">Add new server</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-content-body">
|
||||
<form action="/add_server" class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div >
|
||||
<label for="url">Server management URL</label>
|
||||
<input type="text" class="pure-u-1" name="url" placeholder="https://example.com:5743/KSsdywe6Sdb..."/>
|
||||
<label for="cert">Server management Certificate</label>
|
||||
<input type="text" class="pure-u-1" name="cert" placeholder="B5DD2443DAF..."/>
|
||||
<label for="cert">Server Comment
|
||||
<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" class="pure-u-1" name="comment" placeholder="e.g. server location"/>
|
||||
</div>
|
||||
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if SERVERS|length != 0 and not add_server %}
|
||||
|
||||
{% if selected_server is none %}
|
||||
{% set server = SERVERS[0] %}
|
||||
{% else %}
|
||||
{% set server = SERVERS[selected_server|int] %}
|
||||
{% endif %}
|
||||
<div id="content">
|
||||
<div >
|
||||
<div class="server-content">
|
||||
<div class="server-content-header pure-g">
|
||||
<div>
|
||||
<h1 class="content-title">{{server.info()["name"]}}</h1>
|
||||
<p class="server-content-subtitle">
|
||||
<span>v.{{server.info()["version"]}} {{server.info()["local_server_id"]}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% set ns = namespace(total_bytes=0) %}
|
||||
{% for key in SERVERS[selected_server|int].data["keys"] %}
|
||||
{% if key.used_bytes %}
|
||||
{% set ns.total_bytes = ns.total_bytes + key.used_bytes %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="server-content-body">
|
||||
<h3>Clients: {{ server.info()['keys']|length }}</h3>
|
||||
<h3>Total traffic: {{ ns.total_bytes | filesizeformat }}</h3>
|
||||
<form class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 ">
|
||||
<label for="name">Server Name
|
||||
<span class="pure-form-message">This will not be exposed to client</span>
|
||||
</label>
|
||||
<input type="text" id="name" class="pure-u-1" name="name" value="{{server.info()['name']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="comment">Comment</br>
|
||||
<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-1" name="comment" value="{{server.info()['comment']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1">
|
||||
<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']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<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']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="url">Server URL</label>
|
||||
<input type="text" readonly id="url" class="pure-u-1" name="url" value="{{server.info()['url']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="cert">Server Access Certificate</label>
|
||||
<input type="text" readonly id="cert" class="pure-u-1" name="cert" value="{{server.info()['cert']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<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']) }}"/>
|
||||
</div>
|
||||
<input type="hidden" readonly id="server_id" name="server_id" value="{{server.info()['local_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 button">Save and apply</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<form action="/del_server" method="post">
|
||||
<input type="hidden" name="local_server_id" value="{{ server.info()["local_server_id"] }}">
|
||||
<button type="submit" class="pure-button pure-button-primary delete-button button">Delete Server</button>
|
||||
<input type="checkbox" id="agree" name="agree" required>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -1,17 +0,0 @@
|
||||
<h1>Last sync log</h1>
|
||||
<form action="/sync" class="pure-form pure-form-stacked" method="POST">
|
||||
<p>Wipe ALL keys on ALL servers?</p>
|
||||
<label for="no_wipe" class="pure-radio">
|
||||
<input type="radio" id="no_wipe" name="wipe" value="no_wipe" checked /> No
|
||||
</label>
|
||||
<label for="do_wipe" class="pure-radio">
|
||||
<input type="radio" id="do_wipe" name="wipe" value="all" /> Yes
|
||||
</label>
|
||||
<button type="submit" class="pure-button button-error pure-input-1 ">Sync now</button>
|
||||
</form>
|
||||
|
||||
<pre>
|
||||
<code>
|
||||
{% for line in lines %}{{ line }}{% endfor %}
|
||||
</code>
|
||||
</pre>
|
@ -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."
|
@ -1,93 +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 {
|
||||
# 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
|
||||
}
|
||||
|
0
vpn/__init__.py
Normal file
0
vpn/__init__.py
Normal file
100
vpn/admin.py
Normal file
100
vpn/admin.py
Normal file
@ -0,0 +1,100 @@
|
||||
import json
|
||||
from polymorphic.admin import (
|
||||
PolymorphicParentModelAdmin,
|
||||
)
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.db.models import Count
|
||||
|
||||
from vpn.models import User, ACL
|
||||
from vpn.forms import UserForm
|
||||
from .server_plugins import (
|
||||
Server,
|
||||
WireguardServer,
|
||||
WireguardServerAdmin,
|
||||
OutlineServer,
|
||||
OutlineServerAdmin)
|
||||
|
||||
|
||||
@admin.register(Server)
|
||||
class ServerAdmin(PolymorphicParentModelAdmin):
|
||||
base_model = Server
|
||||
child_models = (OutlineServer, WireguardServer)
|
||||
list_display = ('name', 'server_type', 'comment', 'registration_date', 'user_count', 'server_status_inline')
|
||||
search_fields = ('name', 'comment')
|
||||
list_filter = ('server_type', )
|
||||
|
||||
@admin.display(description='User Count', ordering='user_count')
|
||||
def user_count(self, obj):
|
||||
return obj.user_count
|
||||
|
||||
@admin.display(description='Status')
|
||||
def server_status_inline(self, obj):
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
# Преобразуем JSON в красивый формат
|
||||
import json
|
||||
pretty_status = ", ".join(f"{key}: {value}" for key, value in status.items())
|
||||
return mark_safe(f"<pre>{pretty_status}</pre>")
|
||||
server_status_inline.short_description = "Status"
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(user_count=Count('acl'))
|
||||
return qs
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
form = UserForm
|
||||
list_display = ('name', 'comment', 'registration_date', 'hash', 'server_count')
|
||||
search_fields = ('name', 'hash')
|
||||
readonly_fields = ('hash',)
|
||||
|
||||
|
||||
@admin.display(description='Allowed servers', ordering='server_count')
|
||||
def server_count(self, obj):
|
||||
return obj.server_count
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(server_count=Count('acl'))
|
||||
return qs
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
super().save_model(request, obj, form, change)
|
||||
selected_servers = form.cleaned_data.get('servers', [])
|
||||
|
||||
ACL.objects.filter(user=obj).exclude(server__in=selected_servers).delete()
|
||||
|
||||
for server in selected_servers:
|
||||
ACL.objects.get_or_create(user=obj, server=server)
|
||||
|
||||
|
||||
@admin.register(ACL)
|
||||
class ACLAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'server', 'server_type', 'link', 'created_at')
|
||||
list_filter = ('user', 'server__server_type')
|
||||
search_fields = ('user__name', 'server__name', 'server__comment', 'user__comment', 'link')
|
||||
readonly_fields = ('user_info', )
|
||||
|
||||
@admin.display(description='Server Type', ordering='server__server_type')
|
||||
def server_type(self, obj):
|
||||
return obj.server.get_server_type_display()
|
||||
|
||||
@admin.display(description='Client info')
|
||||
def user_info(self, obj):
|
||||
server = obj.server
|
||||
user = obj.user
|
||||
try:
|
||||
data = server.get_user(user)
|
||||
|
||||
if isinstance(data, dict):
|
||||
formatted_data = json.dumps(data, indent=2)
|
||||
return mark_safe(f"<pre>{formatted_data}</pre>")
|
||||
elif isinstance(data, str):
|
||||
return mark_safe(f"<pre>{data}</pre>")
|
||||
else:
|
||||
return mark_safe(f"<pre>{str(data)}</pre>")
|
||||
except Exception as e:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {e}</span>")
|
6
vpn/apps.py
Normal file
6
vpn/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class VPN(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'vpn'
|
14
vpn/forms.py
Normal file
14
vpn/forms.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
from .models import User
|
||||
from .server_plugins import Server
|
||||
|
||||
class UserForm(forms.ModelForm):
|
||||
servers = forms.ModelMultipleChoiceField(
|
||||
queryset=Server.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['name', 'comment', 'servers']
|
59
vpn/models.py
Normal file
59
vpn/models.py
Normal file
@ -0,0 +1,59 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
from vpn.tasks import sync_user
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from .server_plugins import Server
|
||||
import shortuuid
|
||||
|
||||
class User(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
comment = models.TextField(default="", blank=True)
|
||||
registration_date = models.DateTimeField(auto_now_add=True)
|
||||
servers = models.ManyToManyField('Server', through='ACL', blank=True)
|
||||
last_access = models.DateTimeField(null=True, blank=True)
|
||||
hash = models.CharField(max_length=64, unique=True)
|
||||
|
||||
def get_servers(self):
|
||||
return Server.objects.filter(acl__user=self)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.hash:
|
||||
self.hash = shortuuid.ShortUUID().random(length=16)
|
||||
sync_user.delay_on_commit(self.id)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ACL(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE)
|
||||
server = models.ForeignKey('Server', on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
link = models.CharField(max_length=64, unique=True, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'server'], name='unique_user_server')
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.name} - {self.server.name}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.link:
|
||||
self.link = shortuuid.ShortUUID().random(length=16)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@receiver(post_save, sender=ACL)
|
||||
def acl_created_or_updated(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
sync_user.delay(instance.user.id)
|
||||
else:
|
||||
pass
|
||||
|
||||
@receiver(pre_delete, sender=ACL)
|
||||
def acl_deleted(sender, instance, **kwargs):
|
||||
sync_user.delay(instance.user.id)
|
4
vpn/server_plugins/__init__.py
Normal file
4
vpn/server_plugins/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .generic import Server
|
||||
from .outline import OutlineServer, OutlineServerAdmin
|
||||
from .wireguard import WireguardServer, WireguardServerAdmin
|
||||
from .urls import urlpatterns
|
44
vpn/server_plugins/generic.py
Normal file
44
vpn/server_plugins/generic.py
Normal file
@ -0,0 +1,44 @@
|
||||
from polymorphic.models import PolymorphicModel
|
||||
from django.db import models
|
||||
from vpn.tasks import sync_server
|
||||
|
||||
|
||||
class Server(PolymorphicModel):
|
||||
SERVER_TYPE_CHOICES = (
|
||||
('Outline', 'Outline'),
|
||||
('Wireguard', 'Wireguard'),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
comment = models.TextField(default="", blank=True)
|
||||
registration_date = models.DateTimeField(auto_now_add=True)
|
||||
server_type = models.CharField(max_length=50, choices=SERVER_TYPE_CHOICES, editable=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
sync_server.delay(self.id)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_server_status(self, *args, **kwargs):
|
||||
return {"name": self.name}
|
||||
|
||||
def sync(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def add_user(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get_user(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def delete_user(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Server"
|
||||
verbose_name_plural = "Servers"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
225
vpn/server_plugins/outline.py
Normal file
225
vpn/server_plugins/outline.py
Normal file
@ -0,0 +1,225 @@
|
||||
import logging
|
||||
from venv import logger
|
||||
import requests
|
||||
from django.db import models
|
||||
from .generic import Server
|
||||
from urllib3 import PoolManager
|
||||
from outline_vpn.outline_vpn import OutlineVPN, OutlineLibraryException
|
||||
from polymorphic.admin import PolymorphicChildModelAdmin
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
class OutlineConnectionError(Exception):
|
||||
def __init__(self, message, original_exception=None):
|
||||
super().__init__(message)
|
||||
self.original_exception = original_exception
|
||||
|
||||
class _FingerprintAdapter(requests.adapters.HTTPAdapter):
|
||||
"""
|
||||
This adapter injected into the requests session will check that the
|
||||
fingerprint for the certificate matches for every request
|
||||
"""
|
||||
|
||||
def __init__(self, fingerprint=None, **kwargs):
|
||||
self.fingerprint = str(fingerprint)
|
||||
super(_FingerprintAdapter, self).__init__(**kwargs)
|
||||
|
||||
def init_poolmanager(self, connections, maxsize, block=False):
|
||||
self.poolmanager = PoolManager(
|
||||
num_pools=connections,
|
||||
maxsize=maxsize,
|
||||
block=block,
|
||||
assert_fingerprint=self.fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class OutlineServer(Server):
|
||||
logger = logging.getLogger(__name__)
|
||||
admin_url = models.URLField()
|
||||
admin_access_cert = models.CharField(max_length=255)
|
||||
client_server_name = models.CharField(max_length=255)
|
||||
client_hostname = models.CharField(max_length=255)
|
||||
client_port = models.CharField(max_length=5)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Outline'
|
||||
verbose_name_plural = 'Outline'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.server_type = 'Outline'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self.get_server_status(raw=True)
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
return OutlineVPN(api_url=self.admin_url, cert_sha256=self.admin_access_cert)
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.client_hostname}:{self.client_port})"
|
||||
|
||||
def get_server_status(self, raw=False):
|
||||
status = {}
|
||||
try:
|
||||
info = self.client.get_server_information()
|
||||
if raw:
|
||||
status = info
|
||||
else:
|
||||
status.update(info)
|
||||
except Exception as e:
|
||||
status.update({f"error": e})
|
||||
return status
|
||||
|
||||
def sync(self):
|
||||
status = {}
|
||||
try:
|
||||
state = self.client.get_server_information()
|
||||
if state["name"] != self.name:
|
||||
self.client.set_server_name(self.name)
|
||||
status["name"] = f"{state['name']} -> {self.name}"
|
||||
elif state["hostnameForAccessKeys"] != self.client_hostname:
|
||||
self.client.set_hostname(self.client_hostname)
|
||||
status["hostnameForAccessKeys"] = f"{state['hostnameForAccessKeys']} -> {self.client_hostname}"
|
||||
elif int(state["portForNewAccessKeys"]) != int(self.client_port):
|
||||
self.client.set_port_new_for_access_keys(int(self.client_port))
|
||||
status["portForNewAccessKeys"] = f"{state['portForNewAccessKeys']} -> {self.client_port}"
|
||||
if len(status) == 0:
|
||||
status = {"status": "Nothing to do"}
|
||||
return status
|
||||
except AttributeError as e:
|
||||
raise OutlineConnectionError("Client error. Can't connect.", original_exception=e)
|
||||
|
||||
def _get_key(self, user):
|
||||
try:
|
||||
return self.client.get_key(user.hash)
|
||||
except Exception as e:
|
||||
logger.warning(f"sync error: {e}")
|
||||
return None
|
||||
|
||||
def get_user(self, user, raw=False):
|
||||
user_info = self._get_key(user)
|
||||
if raw:
|
||||
return user_info
|
||||
else:
|
||||
outline_key_dict = user_info.__dict__
|
||||
|
||||
outline_key_dict = {
|
||||
key: value
|
||||
for key, value in user_info.__dict__.items()
|
||||
if not key.startswith('_') and key not in [] # fields to mask
|
||||
}
|
||||
return outline_key_dict
|
||||
|
||||
|
||||
def add_user(self, user):
|
||||
server_user = self._get_key(user)
|
||||
logger.warning(server_user)
|
||||
result = {}
|
||||
key = None
|
||||
|
||||
if server_user:
|
||||
self.client.delete_key(user.hash)
|
||||
key = self.client.create_key(
|
||||
name=user.name,
|
||||
method=server_user.method,
|
||||
password=user.hash,
|
||||
data_limit=None,
|
||||
port=server_user.port
|
||||
)
|
||||
else:
|
||||
key = self.client.create_key(
|
||||
key_id=user.hash,
|
||||
name=user.name,
|
||||
method=server_user.method,
|
||||
password=user.hash,
|
||||
data_limit=None,
|
||||
port=server_user.port
|
||||
)
|
||||
try:
|
||||
result['key_id'] = key.key_id
|
||||
result['name'] = key.name
|
||||
result['method'] = key.method
|
||||
result['password'] = key.password
|
||||
result['data_limit'] = key.data_limit
|
||||
result['port'] = key.port
|
||||
except Exception as e:
|
||||
result = {"error": str(e)}
|
||||
return result
|
||||
|
||||
def delete_user(self, user):
|
||||
server_user = self._get_key(user)
|
||||
result = None
|
||||
|
||||
if server_user:
|
||||
self.logger.info(f"[{self.name}] TEST")
|
||||
self.client.delete_key(server_user.key_id)
|
||||
result = {"status": "User was deleted"}
|
||||
self.logger.info(f"[{self.name}] User deleted: {user.name} on server {self.name}")
|
||||
else:
|
||||
result = {"status": "User absent, nothing to do."}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
class OutlineServerAdmin(PolymorphicChildModelAdmin):
|
||||
base_model = OutlineServer
|
||||
show_in_index = False # Не отображать в главном списке админки
|
||||
list_display = (
|
||||
'name',
|
||||
'admin_url',
|
||||
'admin_access_cert',
|
||||
'client_server_name',
|
||||
'client_hostname',
|
||||
'client_port',
|
||||
'server_status_inline',
|
||||
'user_count',
|
||||
'registration_date'
|
||||
)
|
||||
readonly_fields = ('server_status_full', )
|
||||
exclude = ('server_type',)
|
||||
|
||||
@admin.display(description='Clients', ordering='user_count')
|
||||
def user_count(self, obj):
|
||||
return obj.user_count
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(user_count=Count('acl__user'))
|
||||
return qs
|
||||
|
||||
def server_status_inline(self, obj):
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
# Преобразуем JSON в красивый формат
|
||||
import json
|
||||
pretty_status = json.dumps(status, indent=4)
|
||||
return mark_safe(f"<pre>{pretty_status}</pre>")
|
||||
server_status_inline.short_description = "Status"
|
||||
|
||||
def server_status_full(self, obj):
|
||||
if obj and obj.pk:
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
import json
|
||||
pretty_status = json.dumps(status, indent=4)
|
||||
return mark_safe(f"<pre>{pretty_status}</pre>")
|
||||
return "N/A"
|
||||
|
||||
server_status_full.short_description = "Server Status"
|
||||
|
||||
def get_model_perms(self, request):
|
||||
"""It disables display for sub-model"""
|
||||
return {}
|
||||
|
||||
admin.site.register(OutlineServer, OutlineServerAdmin)
|
6
vpn/server_plugins/urls.py
Normal file
6
vpn/server_plugins/urls.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.urls import path
|
||||
from vpn.views import shadowsocks
|
||||
|
||||
urlpatterns = [
|
||||
path('ss/<str:hash_value>/', shadowsocks, name='shadowsocks'),
|
||||
]
|
83
vpn/server_plugins/wireguard.py
Normal file
83
vpn/server_plugins/wireguard.py
Normal file
@ -0,0 +1,83 @@
|
||||
from .generic import Server
|
||||
from django.db import models
|
||||
from polymorphic.admin import (
|
||||
PolymorphicChildModelAdmin,
|
||||
)
|
||||
from django.contrib import admin
|
||||
from django.db.models import Count
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
class WireguardServer(Server):
|
||||
address = models.CharField(max_length=100)
|
||||
port = models.IntegerField()
|
||||
client_private_key = models.CharField(max_length=255)
|
||||
server_publick_key = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Wireguard'
|
||||
verbose_name_plural = 'Wireguard'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.server_type = 'Wireguard'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.address})"
|
||||
|
||||
def get_server_status(self):
|
||||
status = super().get_server_status()
|
||||
status.update({
|
||||
"address": self.address,
|
||||
"port": self.port,
|
||||
"client_private_key": self.client_private_key,
|
||||
"server_publick_key": self.server_publick_key,
|
||||
})
|
||||
return status
|
||||
|
||||
class WireguardServerAdmin(PolymorphicChildModelAdmin):
|
||||
base_model = WireguardServer
|
||||
show_in_index = False # Не отображать в главном списке админки
|
||||
list_display = (
|
||||
'name',
|
||||
'address',
|
||||
'port',
|
||||
'server_publick_key',
|
||||
'client_private_key',
|
||||
'server_status_inline',
|
||||
'user_count',
|
||||
'registration_date'
|
||||
)
|
||||
readonly_fields = ('server_status_full', )
|
||||
exclude = ('server_type',)
|
||||
|
||||
@admin.display(description='Clients', ordering='user_count')
|
||||
def user_count(self, obj):
|
||||
return obj.user_count
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(user_count=Count('acl__user'))
|
||||
return qs
|
||||
|
||||
def server_status_inline(self, obj):
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
return mark_safe(f"<pre>{status}</pre>")
|
||||
server_status_inline.short_description = "Server Status"
|
||||
|
||||
def server_status_full(self, obj):
|
||||
if obj and obj.pk:
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
return mark_safe(f"<pre>{status}</pre>")
|
||||
return "N/A"
|
||||
|
||||
server_status_full.short_description = "Server Status"
|
||||
|
||||
def get_model_perms(self, request):
|
||||
"""It disables display for sub-model"""
|
||||
return {}
|
||||
|
||||
admin.site.register(WireguardServer, WireguardServerAdmin)
|
50
vpn/tasks.py
Normal file
50
vpn/tasks.py
Normal file
@ -0,0 +1,50 @@
|
||||
|
||||
import logging
|
||||
from celery import shared_task
|
||||
#from django_celery_results.models import TaskResult
|
||||
from outline_vpn.outline_vpn import OutlineServerErrorException
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TaskFailedException(Exception):
|
||||
def __init__(self, message=""):
|
||||
self.message = message
|
||||
super().__init__(f"{self.message}")
|
||||
|
||||
|
||||
@shared_task(name="sync.server")
|
||||
def sync_server(id):
|
||||
from vpn.server_plugins import Server
|
||||
# task_result = TaskResult.objects.get_task(self.request.id)
|
||||
# task_result.status='RUNNING'
|
||||
# task_result.save()
|
||||
return {"status": Server.objects.get(id=id).sync()}
|
||||
|
||||
@shared_task(name="sync.user")
|
||||
def sync_user(id):
|
||||
from .models import User, ACL
|
||||
from vpn.server_plugins import Server
|
||||
|
||||
errors = {}
|
||||
result = {}
|
||||
user = User.objects.get(id=id)
|
||||
acls = ACL.objects.filter(user=user)
|
||||
|
||||
servers = Server.objects.all()
|
||||
|
||||
for server in servers:
|
||||
try:
|
||||
if acls.filter(server=server).exists():
|
||||
result[server.name] = server.add_user(user)
|
||||
else:
|
||||
result[server.name] = server.delete_user(user)
|
||||
except Exception as e:
|
||||
errors[server.name] = {"error": e}
|
||||
finally:
|
||||
if errors:
|
||||
logger.error("ERROR ERROR")
|
||||
raise TaskFailedException(message=f"Errors during taks: {errors}")
|
||||
else:
|
||||
logger.error(f"PUK PUEK. {errors}")
|
||||
return result
|
10
vpn/templates/admin/polls/user/change_form.html
Normal file
10
vpn/templates/admin/polls/user/change_form.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block after_field_sets %}
|
||||
<div>
|
||||
<h2>Create ACLs</h2>
|
||||
{{ adminform.form.servers }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
3
vpn/tests.py
Normal file
3
vpn/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
26
vpn/views.py
Normal file
26
vpn/views.py
Normal file
@ -0,0 +1,26 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
|
||||
# views.py
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def shadowsocks(request, link):
|
||||
from .models import ACL
|
||||
acl = get_object_or_404(ACL, link=link)
|
||||
server_user = acl.server.get_user(acl.user, raw=True)
|
||||
config = {
|
||||
"info": "Managed by OutFleet_v2 [github.com/house-of-vanity/OutFleet/]",
|
||||
"password": server_user.password,
|
||||
"method": server_user.method,
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001",
|
||||
"server": acl.server.client_server_name,
|
||||
"server_port": server_user.port,
|
||||
"access_url": server_user.access_url,
|
||||
}
|
||||
return JsonResponse(config)
|
||||
|
||||
|
Reference in New Issue
Block a user