diff --git a/.gitignore b/.gitignore index 105f280..48bffb6 100755 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile old mode 100755 new mode 100644 index 2cdb635..e2b75eb --- a/Dockerfile +++ b/Dockerfile @@ -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" ] diff --git a/img/servers.png b/img/servers.png deleted file mode 100755 index 307047b..0000000 Binary files a/img/servers.png and /dev/null differ diff --git a/k8s.py b/k8s.py deleted file mode 100755 index 3cd5f79..0000000 --- a/k8s.py +++ /dev/null @@ -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") - diff --git a/lib.py b/lib.py deleted file mode 100755 index 71b2ecd..0000000 --- a/lib.py +++ /dev/null @@ -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) diff --git a/main.py b/main.py deleted file mode 100755 index 05fe691..0000000 --- a/main.py +++ /dev/null @@ -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/", 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") diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..a7da667 --- /dev/null +++ b/manage.py @@ -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() diff --git a/mysite/__init__.py b/mysite/__init__.py new file mode 100644 index 0000000..9e0d95f --- /dev/null +++ b/mysite/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/mysite/asgi.py b/mysite/asgi.py new file mode 100644 index 0000000..44c7dff --- /dev/null +++ b/mysite/asgi.py @@ -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() diff --git a/mysite/celery.py b/mysite/celery.py new file mode 100644 index 0000000..2cd6ae9 --- /dev/null +++ b/mysite/celery.py @@ -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() + diff --git a/mysite/middleware.py b/mysite/middleware.py new file mode 100644 index 0000000..050ce11 --- /dev/null +++ b/mysite/middleware.py @@ -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 diff --git a/mysite/settings.py b/mysite/settings.py new file mode 100644 index 0000000..eaad95c --- /dev/null +++ b/mysite/settings.py @@ -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' diff --git a/mysite/urls.py b/mysite/urls.py new file mode 100644 index 0000000..cc5f523 --- /dev/null +++ b/mysite/urls.py @@ -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/', shadowsocks, name='shadowsocks'), + path('dynamic/', shadowsocks, name='shadowsocks'), + path('', RedirectView.as_view(url='/admin/', permanent=False)), +] \ No newline at end of file diff --git a/mysite/wsgi.py b/mysite/wsgi.py new file mode 100644 index 0000000..61b0d9d --- /dev/null +++ b/mysite/wsgi.py @@ -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() diff --git a/requirements.txt b/requirements.txt old mode 100755 new mode 100644 index cd74dba..d4940a9 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,14 @@ -outline-vpn-api -kubernetes -PyYAML>=6.0.1 -Flask>=2.3.3 -flask-cors -bcrypt \ No newline at end of file +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 \ No newline at end of file diff --git a/static/admin/js/generate_uuid.js b/static/admin/js/generate_uuid.js new file mode 100644 index 0000000..d568c30 --- /dev/null +++ b/static/admin/js/generate_uuid.js @@ -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(); +}); diff --git a/static/layout.css b/static/layout.css deleted file mode 100755 index 7fa6628..0000000 --- a/static/layout.css +++ /dev/null @@ -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%; -} \ No newline at end of file diff --git a/static/pure.css b/static/pure.css deleted file mode 100755 index acdc431..0000000 --- a/static/pure.css +++ /dev/null @@ -1,11 +0,0 @@ -/*! -Pure v3.0.0 -Copyright 2013 Yahoo! -Licensed under the BSD License. -https://github.com/pure-css/pure/blob/master/LICENSE -*/ -/*! -normalize.css v | MIT License | https://necolas.github.io/normalize.css/ -Copyright (c) Nicolas Gallagher and Jonathan Neal -*/ -/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}html{font-family:sans-serif}.hidden,[hidden]{display:none!important}.pure-img{max-width:100%;height:auto;display:block}.pure-g{display:flex;flex-flow:row wrap;align-content:flex-start}.pure-u{display:inline-block;vertical-align:top}.pure-u-1,.pure-u-1-1,.pure-u-1-12,.pure-u-1-2,.pure-u-1-24,.pure-u-1-3,.pure-u-1-4,.pure-u-1-5,.pure-u-1-6,.pure-u-1-8,.pure-u-10-24,.pure-u-11-12,.pure-u-11-24,.pure-u-12-24,.pure-u-13-24,.pure-u-14-24,.pure-u-15-24,.pure-u-16-24,.pure-u-17-24,.pure-u-18-24,.pure-u-19-24,.pure-u-2-24,.pure-u-2-3,.pure-u-2-5,.pure-u-20-24,.pure-u-21-24,.pure-u-22-24,.pure-u-23-24,.pure-u-24-24,.pure-u-3-24,.pure-u-3-4,.pure-u-3-5,.pure-u-3-8,.pure-u-4-24,.pure-u-4-5,.pure-u-5-12,.pure-u-5-24,.pure-u-5-5,.pure-u-5-6,.pure-u-5-8,.pure-u-6-24,.pure-u-7-12,.pure-u-7-24,.pure-u-7-8,.pure-u-8-24,.pure-u-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1-24{width:4.1667%}.pure-u-1-12,.pure-u-2-24{width:8.3333%}.pure-u-1-8,.pure-u-3-24{width:12.5%}.pure-u-1-6,.pure-u-4-24{width:16.6667%}.pure-u-1-5{width:20%}.pure-u-5-24{width:20.8333%}.pure-u-1-4,.pure-u-6-24{width:25%}.pure-u-7-24{width:29.1667%}.pure-u-1-3,.pure-u-8-24{width:33.3333%}.pure-u-3-8,.pure-u-9-24{width:37.5%}.pure-u-2-5{width:40%}.pure-u-10-24,.pure-u-5-12{width:41.6667%}.pure-u-11-24{width:45.8333%}.pure-u-1-2,.pure-u-12-24{width:50%}.pure-u-13-24{width:54.1667%}.pure-u-14-24,.pure-u-7-12{width:58.3333%}.pure-u-3-5{width:60%}.pure-u-15-24,.pure-u-5-8{width:62.5%}.pure-u-16-24,.pure-u-2-3{width:66.6667%}.pure-u-17-24{width:70.8333%}.pure-u-18-24,.pure-u-3-4{width:75%}.pure-u-19-24{width:79.1667%}.pure-u-4-5{width:80%}.pure-u-20-24,.pure-u-5-6{width:83.3333%}.pure-u-21-24,.pure-u-7-8{width:87.5%}.pure-u-11-12,.pure-u-22-24{width:91.6667%}.pure-u-23-24{width:95.8333%}.pure-u-1,.pure-u-1-1,.pure-u-24-24,.pure-u-5-5{width:100%}.pure-button{display:inline-block;line-height:normal;white-space:nowrap;vertical-align:middle;text-align:center;cursor:pointer;-webkit-user-drag:none;-webkit-user-select:none;user-select:none;box-sizing:border-box}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button-group{letter-spacing:-.31em;text-rendering:optimizespeed}.opera-only :-o-prefocus,.pure-button-group{word-spacing:-0.43em}.pure-button-group .pure-button{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-button{font-family:inherit;font-size:100%;padding:.5em 1em;color:rgba(0,0,0,.8);border:none transparent;background-color:#e6e6e6;text-decoration:none;border-radius:2px}.pure-button-hover,.pure-button:focus,.pure-button:hover{background-image:linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1))}.pure-button:focus{outline:0}.pure-button-active,.pure-button:active{box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset;border-color:#000}.pure-button-disabled,.pure-button-disabled:active,.pure-button-disabled:focus,.pure-button-disabled:hover,.pure-button[disabled]{border:none;background-image:none;opacity:.4;cursor:not-allowed;box-shadow:none;pointer-events:none}.pure-button-hidden{display:none}.pure-button-primary,.pure-button-selected,a.pure-button-primary,a.pure-button-selected{background-color:#0078e7;color:#fff}.pure-button-group .pure-button{margin:0;border-radius:0;border-right:1px solid rgba(0,0,0,.2)}.pure-button-group .pure-button:first-child{border-top-left-radius:2px;border-bottom-left-radius:2px}.pure-button-group .pure-button:last-child{border-top-right-radius:2px;border-bottom-right-radius:2px;border-right:none}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;vertical-align:middle;box-sizing:border-box}.pure-form input:not([type]){padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;box-sizing:border-box}.pure-form input[type=color]{padding:.2em .5em}.pure-form input[type=color]:focus,.pure-form input[type=date]:focus,.pure-form input[type=datetime-local]:focus,.pure-form input[type=datetime]:focus,.pure-form input[type=email]:focus,.pure-form input[type=month]:focus,.pure-form input[type=number]:focus,.pure-form input[type=password]:focus,.pure-form input[type=search]:focus,.pure-form input[type=tel]:focus,.pure-form input[type=text]:focus,.pure-form input[type=time]:focus,.pure-form input[type=url]:focus,.pure-form input[type=week]:focus,.pure-form select:focus,.pure-form textarea:focus{outline:0;border-color:#129fea}.pure-form input:not([type]):focus{outline:0;border-color:#129fea}.pure-form input[type=checkbox]:focus,.pure-form input[type=file]:focus,.pure-form input[type=radio]:focus{outline:thin solid #129FEA;outline:1px auto #129FEA}.pure-form .pure-checkbox,.pure-form .pure-radio{margin:.5em 0;display:block}.pure-form input[type=color][disabled],.pure-form input[type=date][disabled],.pure-form input[type=datetime-local][disabled],.pure-form input[type=datetime][disabled],.pure-form input[type=email][disabled],.pure-form input[type=month][disabled],.pure-form input[type=number][disabled],.pure-form input[type=password][disabled],.pure-form input[type=search][disabled],.pure-form input[type=tel][disabled],.pure-form input[type=text][disabled],.pure-form input[type=time][disabled],.pure-form input[type=url][disabled],.pure-form input[type=week][disabled],.pure-form select[disabled],.pure-form textarea[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input:not([type])[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input[readonly],.pure-form select[readonly],.pure-form textarea[readonly]{background-color:#eee;color:#777;border-color:#ccc}.pure-form input:focus:invalid,.pure-form select:focus:invalid,.pure-form textarea:focus:invalid{color:#b94a48;border-color:#e9322d}.pure-form input[type=checkbox]:focus:invalid:focus,.pure-form input[type=file]:focus:invalid:focus,.pure-form input[type=radio]:focus:invalid:focus{outline-color:#e9322d}.pure-form select{height:2.25em;border:1px solid #ccc;background-color:#fff}.pure-form select[multiple]{height:auto}.pure-form label{margin:.5em 0 .2em}.pure-form fieldset{margin:0;padding:.35em 0 .75em;border:0}.pure-form legend{display:block;width:100%;padding:.3em 0;margin-bottom:.3em;color:#333;border-bottom:1px solid #e5e5e5}.pure-form-stacked input[type=color],.pure-form-stacked input[type=date],.pure-form-stacked input[type=datetime-local],.pure-form-stacked input[type=datetime],.pure-form-stacked input[type=email],.pure-form-stacked input[type=file],.pure-form-stacked input[type=month],.pure-form-stacked input[type=number],.pure-form-stacked input[type=password],.pure-form-stacked input[type=search],.pure-form-stacked input[type=tel],.pure-form-stacked input[type=text],.pure-form-stacked input[type=time],.pure-form-stacked input[type=url],.pure-form-stacked input[type=week],.pure-form-stacked label,.pure-form-stacked select,.pure-form-stacked textarea{display:block;margin:.25em 0}.pure-form-stacked input:not([type]){display:block;margin:.25em 0}.pure-form-aligned input,.pure-form-aligned select,.pure-form-aligned textarea,.pure-form-message-inline{display:inline-block;vertical-align:middle}.pure-form-aligned textarea{vertical-align:top}.pure-form-aligned .pure-control-group{margin-bottom:.5em}.pure-form-aligned .pure-control-group label{text-align:right;display:inline-block;vertical-align:middle;width:10em;margin:0 1em 0 0}.pure-form-aligned .pure-controls{margin:1.5em 0 0 11em}.pure-form .pure-input-rounded,.pure-form input.pure-input-rounded{border-radius:2em;padding:.5em 1em}.pure-form .pure-group fieldset{margin-bottom:10px}.pure-form .pure-group input,.pure-form .pure-group textarea{display:block;padding:10px;margin:0 0 -1px;border-radius:0;position:relative;top:-1px}.pure-form .pure-group input:focus,.pure-form .pure-group textarea:focus{z-index:3}.pure-form .pure-group input:first-child,.pure-form .pure-group textarea:first-child{top:1px;border-radius:4px 4px 0 0;margin:0}.pure-form .pure-group input:first-child:last-child,.pure-form .pure-group textarea:first-child:last-child{top:1px;border-radius:4px;margin:0}.pure-form .pure-group input:last-child,.pure-form .pure-group textarea:last-child{top:-2px;border-radius:0 0 4px 4px;margin:0}.pure-form .pure-group button{margin:.35em 0}.pure-form .pure-input-1{width:100%}.pure-form .pure-input-3-4{width:75%}.pure-form .pure-input-2-3{width:66%}.pure-form .pure-input-1-2{width:50%}.pure-form .pure-input-1-3{width:33%}.pure-form .pure-input-1-4{width:25%}.pure-form-message-inline{display:inline-block;padding-left:.3em;color:#666;vertical-align:middle;font-size:.875em}.pure-form-message{display:block;color:#666;font-size:.875em}@media only screen and (max-width :480px){.pure-form button[type=submit]{margin:.7em 0 0}.pure-form input:not([type]),.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form label{margin-bottom:.3em;display:block}.pure-group input:not([type]),.pure-group input[type=color],.pure-group input[type=date],.pure-group input[type=datetime-local],.pure-group input[type=datetime],.pure-group input[type=email],.pure-group input[type=month],.pure-group input[type=number],.pure-group input[type=password],.pure-group input[type=search],.pure-group input[type=tel],.pure-group input[type=text],.pure-group input[type=time],.pure-group input[type=url],.pure-group input[type=week]{margin-bottom:0}.pure-form-aligned .pure-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.pure-form-aligned .pure-controls{margin:1.5em 0 0 0}.pure-form-message,.pure-form-message-inline{display:block;font-size:.75em;padding:.2em 0 .8em}}.pure-menu{box-sizing:border-box}.pure-menu-fixed{position:fixed;left:0;top:0;z-index:3}.pure-menu-item,.pure-menu-list{position:relative}.pure-menu-list{list-style:none;margin:0;padding:0}.pure-menu-item{padding:0;margin:0;height:100%}.pure-menu-heading,.pure-menu-link{display:block;text-decoration:none;white-space:nowrap}.pure-menu-horizontal{width:100%;white-space:nowrap}.pure-menu-horizontal .pure-menu-list{display:inline-block}.pure-menu-horizontal .pure-menu-heading,.pure-menu-horizontal .pure-menu-item,.pure-menu-horizontal .pure-menu-separator{display:inline-block;vertical-align:middle}.pure-menu-item .pure-menu-item{display:block}.pure-menu-children{display:none;position:absolute;left:100%;top:0;margin:0;padding:0;z-index:3}.pure-menu-horizontal .pure-menu-children{left:0;top:auto;width:inherit}.pure-menu-active>.pure-menu-children,.pure-menu-allow-hover:hover>.pure-menu-children{display:block;position:absolute}.pure-menu-has-children>.pure-menu-link:after{padding-left:.5em;content:"\25B8";font-size:small}.pure-menu-horizontal .pure-menu-has-children>.pure-menu-link:after{content:"\25BE"}.pure-menu-scrollable{overflow-y:scroll;overflow-x:hidden}.pure-menu-scrollable .pure-menu-list{display:block}.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list{display:inline-block}.pure-menu-horizontal.pure-menu-scrollable{white-space:nowrap;overflow-y:hidden;overflow-x:auto;padding:.5em 0}.pure-menu-horizontal .pure-menu-children .pure-menu-separator,.pure-menu-separator{background-color:#ccc;height:1px;margin:.3em 0}.pure-menu-horizontal .pure-menu-separator{width:1px;height:1.3em;margin:0 .3em}.pure-menu-horizontal .pure-menu-children .pure-menu-separator{display:block;width:auto}.pure-menu-heading{text-transform:uppercase;color:#565d64}.pure-menu-link{color:#777}.pure-menu-children{background-color:#fff}.pure-menu-heading,.pure-menu-link{padding:.5em 1em}.pure-menu-disabled{opacity:.5}.pure-menu-disabled .pure-menu-link:hover{background-color:transparent;cursor:default}.pure-menu-active>.pure-menu-link,.pure-menu-link:focus,.pure-menu-link:hover{background-color:#eee}.pure-menu-selected>.pure-menu-link,.pure-menu-selected>.pure-menu-link:visited{color:#000}.pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid #cbcbcb}.pure-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.pure-table td,.pure-table th{border-left:1px solid #cbcbcb;border-width:0 0 0 1px;font-size:inherit;margin:0;overflow:visible;padding:.5em 1em}.pure-table thead{background-color:#e0e0e0;color:#000;text-align:left;vertical-align:bottom}.pure-table td{background-color:transparent}.pure-table-odd td{background-color:#f2f2f2}.pure-table-striped tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid #cbcbcb}.pure-table-bordered tbody>tr:last-child>td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0 1px 0;border-bottom:1px solid #cbcbcb}.pure-table-horizontal tbody>tr:last-child>td{border-bottom-width:0} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index 8e2970e..0000000 --- a/templates/base.html +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - {% block title %}Dashboard{% endblock %} - - - - - - - -
- - {% block content %}{% endblock %} - - - - - -
- - - - - - - {% if nt %} - - {% endif %} - - - \ No newline at end of file diff --git a/templates/clients.html b/templates/clients.html deleted file mode 100644 index f8d7e12..0000000 --- a/templates/clients.html +++ /dev/null @@ -1,167 +0,0 @@ -{% extends "base.html" %} - -{% block content %} - -
-
-

Clients

-
- -
-
- {% for client, values in CLIENTS.items() %} -
-
-
{{ values["name"] }}
-

{{ values["servers"]|length }} server{% if values["servers"]|length >1 - %}s{%endif%}

-
-
- {% endfor %} -
- -
-
- + -
-
- -
-
- -{% if add_client %} -
-
-
-
-

Add new client

-
-
-
-
-
-
-
- -
-
- -
-
- {% for server in SERVERS %} -
- - -
- {% endfor %} - -
- -
- -
-
-
-
-
-{% endif %} - - -{% if selected_client and not add_client %} -{% set client = CLIENTS[selected_client] %} - -
-
-
-
-

{{client['name']}}

-

{{ client['comment'] }}

-

id {{ selected_client }}

- -
-
-
-
-
-
-
- - -
-
- -
- - -
- -

Allow access to:

- - {% for server in SERVERS %} -
- - -
- {% endfor %} -
- -
-
-
- -
-
- -
-
-
- - - - -
- -
-

Invite text

-
-

Install Outline VPN. Copy and paste the keys below into the Outline client. - The same keys can be used simultaneously on multiple devices.

- {% 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') %} - -

Server location: {{server.info()['name']}}

-

Client link: {% 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 %}

- {% endif %} - {%- endfor -%} -
- -
-
-
-{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 316e47f..0000000 --- a/templates/index.html +++ /dev/null @@ -1,159 +0,0 @@ -{% extends "base.html" %} - -{% block content %} - - - -
-
-

Servers

-
- {% 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 %} -
-
-
{{ server.info()["name"] }}
-

{{ '/'.join(server.info()["url"].split('/')[0:-1]) }}

-

Port {{ server.info()["port_for_new_access_keys"] }}

-

Hostname {{ server.info()["hostname_for_access_keys"] }}

-

Traffic: {{ total_traffic.total_bytes | filesizeformat }}

-

v.{{ server.info()["version"] }}

-

- {{ server.info()["comment"] }} -

-
-
- {% endfor %} -
-
-
- + -
-
- -
-
- -{% if add_server %} -
-
-
-
-

Add new server

-
-
-
-
-
-
- - - - - - -
- -
-
-
-
-
-{% 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 %} -
-
-
-
-
-

{{server.info()["name"]}}

-

- v.{{server.info()["version"]}} {{server.info()["local_server_id"]}} -

-
- -
- - {% 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 %} -
-

Clients: {{ server.info()['keys']|length }}

-

Total traffic: {{ ns.total_bytes | filesizeformat }}

-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-

Share anonymous metrics

- - - -
-
-
- - - -
- -
-
-
-
- -{% endif %} - -{% endblock %} diff --git a/templates/sync.html b/templates/sync.html deleted file mode 100644 index 292cbf5..0000000 --- a/templates/sync.html +++ /dev/null @@ -1,17 +0,0 @@ -

Last sync log

-
-

Wipe ALL keys on ALL servers?

- - - -
- -
-    
-{% for line in lines %}{{ line }}{% endfor %}
-    
-
diff --git a/tools/windows-helper.ps1 b/tools/windows-helper.ps1 deleted file mode 100644 index 539c7dc..0000000 --- a/tools/windows-helper.ps1 +++ /dev/null @@ -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." diff --git a/tools/windows_task.ps1 b/tools/windows_task.ps1 deleted file mode 100644 index 25b2f9f..0000000 --- a/tools/windows_task.ps1 +++ /dev/null @@ -1,93 +0,0 @@ -if ($args.Count -lt 3) { - Write-Host "Usage: windows_task.ps1 " - 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 -} - diff --git a/vpn/__init__.py b/vpn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vpn/admin.py b/vpn/admin.py new file mode 100644 index 0000000..1b3b307 --- /dev/null +++ b/vpn/admin.py @@ -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"Error: {status['error']}") + # Преобразуем JSON в красивый формат + import json + pretty_status = ", ".join(f"{key}: {value}" for key, value in status.items()) + return mark_safe(f"
{pretty_status}
") + 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"
{formatted_data}
") + elif isinstance(data, str): + return mark_safe(f"
{data}
") + else: + return mark_safe(f"
{str(data)}
") + except Exception as e: + return mark_safe(f"Error: {e}") \ No newline at end of file diff --git a/vpn/apps.py b/vpn/apps.py new file mode 100644 index 0000000..e3350dd --- /dev/null +++ b/vpn/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class VPN(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'vpn' diff --git a/vpn/forms.py b/vpn/forms.py new file mode 100644 index 0000000..098db53 --- /dev/null +++ b/vpn/forms.py @@ -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'] diff --git a/vpn/models.py b/vpn/models.py new file mode 100644 index 0000000..6b2cbe2 --- /dev/null +++ b/vpn/models.py @@ -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) \ No newline at end of file diff --git a/vpn/server_plugins/__init__.py b/vpn/server_plugins/__init__.py new file mode 100644 index 0000000..0f7ec24 --- /dev/null +++ b/vpn/server_plugins/__init__.py @@ -0,0 +1,4 @@ +from .generic import Server +from .outline import OutlineServer, OutlineServerAdmin +from .wireguard import WireguardServer, WireguardServerAdmin +from .urls import urlpatterns \ No newline at end of file diff --git a/vpn/server_plugins/generic.py b/vpn/server_plugins/generic.py new file mode 100644 index 0000000..4b10611 --- /dev/null +++ b/vpn/server_plugins/generic.py @@ -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 diff --git a/vpn/server_plugins/outline.py b/vpn/server_plugins/outline.py new file mode 100644 index 0000000..52abed4 --- /dev/null +++ b/vpn/server_plugins/outline.py @@ -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"Error: {status['error']}") + # Преобразуем JSON в красивый формат + import json + pretty_status = json.dumps(status, indent=4) + return mark_safe(f"
{pretty_status}
") + 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"Error: {status['error']}") + import json + pretty_status = json.dumps(status, indent=4) + return mark_safe(f"
{pretty_status}
") + 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) \ No newline at end of file diff --git a/vpn/server_plugins/urls.py b/vpn/server_plugins/urls.py new file mode 100644 index 0000000..53385d7 --- /dev/null +++ b/vpn/server_plugins/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from vpn.views import shadowsocks + +urlpatterns = [ + path('ss//', shadowsocks, name='shadowsocks'), +] \ No newline at end of file diff --git a/vpn/server_plugins/wireguard.py b/vpn/server_plugins/wireguard.py new file mode 100644 index 0000000..a9556e8 --- /dev/null +++ b/vpn/server_plugins/wireguard.py @@ -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"Error: {status['error']}") + return mark_safe(f"
{status}
") + 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"Error: {status['error']}") + return mark_safe(f"
{status}
") + 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) \ No newline at end of file diff --git a/vpn/tasks.py b/vpn/tasks.py new file mode 100644 index 0000000..77fe0a6 --- /dev/null +++ b/vpn/tasks.py @@ -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 \ No newline at end of file diff --git a/vpn/templates/admin/polls/user/change_form.html b/vpn/templates/admin/polls/user/change_form.html new file mode 100644 index 0000000..9b8ed3b --- /dev/null +++ b/vpn/templates/admin/polls/user/change_form.html @@ -0,0 +1,10 @@ +{% extends "admin/change_form.html" %} +{% load static %} + +{% block after_field_sets %} +
+

Create ACLs

+ {{ adminform.form.servers }} +
+{% endblock %} + diff --git a/vpn/tests.py b/vpn/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/vpn/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/vpn/views.py b/vpn/views.py new file mode 100644 index 0000000..7d55e17 --- /dev/null +++ b/vpn/views.py @@ -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) + +