mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2026-11-09 01:08:49 +00:00
Django UI
This commit is contained in:
1 parent
9680ce802d
commit
7bf998ece5
38 files changed
+1003
-1978
No files matched your search
+6
-7
@@ -1,9 +1,8 @@
|
|||||||
config.yaml
|
db.sqlite3
|
||||||
__pycache__/
|
debug.log
|
||||||
sync.log
|
|
||||||
main.py
|
|
||||||
.idea/*
|
|
||||||
.vscode/*
|
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*.swn
|
*.pyc
|
||||||
|
staticfiles/
|
||||||
|
*.__pycache__.*
|
||||||
|
vpn/migrations/
|
||||||
Executable → Regular
+5
-8
@@ -1,13 +1,10 @@
|
|||||||
FROM python:3-alpine
|
FROM python:3
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt ./
|
||||||
COPY static static
|
|
||||||
COPY templates templates
|
|
||||||
COPY *.py .
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
EXPOSE 5000
|
COPY . .
|
||||||
CMD ["python", "main.py"]
|
|
||||||
|
CMD [ "python", "./manage.py", "runserver", "0.0.0.0:8000" ]
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
@@ -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")
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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")
|
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ('celery_app',)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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)),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
Executable → Regular
+14
-6
@@ -1,6 +1,14 @@
|
|||||||
outline-vpn-api
|
django-environ==0.11.2
|
||||||
kubernetes
|
Django==5.1.2
|
||||||
PyYAML>=6.0.1
|
celery==5.4.0
|
||||||
Flask>=2.3.3
|
django-jazzmin==3.0.1
|
||||||
flask-cors
|
django-polymorphic==3.1.0
|
||||||
bcrypt
|
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
|
||||||
@@ -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%;
|
|
||||||
}
|
|
||||||
@@ -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-buLine truncated
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
|
|
||||||
<title>{% block title %}Dashboard{% endblock %}</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='pure.css') }}">
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='layout.css') }}">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div id="layout">
|
|
||||||
<div id="menu">
|
|
||||||
<div id="nav">
|
|
||||||
<a href="#" id="menuLink" class="nav-menu-button">Menu</a>
|
|
||||||
<div class="nav-inner">
|
|
||||||
<div class="pure-menu custom-restricted-width">
|
|
||||||
<ul class="pure-menu-list">
|
|
||||||
<li class="pure-menu-item"><a href="/" class="pure-menu-link">Servers</a></li>
|
|
||||||
<li class="pure-menu-item"><a href="/clients" class="pure-menu-link">Clients</a></li>
|
|
||||||
<li class="pure-menu-item"><a href="/sync" class="pure-menu-link">Sync status</a></li>
|
|
||||||
</ul>
|
|
||||||
<div style="padding-left: 20%;padding-top: 50px;">
|
|
||||||
{% if K8S_NAMESPACE %}<abbr title="Works on K8S"><svg xmlns="http://www.w3.org/2000/svg" width="25%"
|
|
||||||
viewBox="0 0 256 249">
|
|
||||||
<path fill="#326de6"
|
|
||||||
d="M82.085 244.934c-5.946 0-11.561-2.642-15.36-7.432L8.92 165.657c-3.799-4.79-5.285-10.9-3.799-16.847l20.645-89.682c1.321-5.946 5.285-10.736 10.736-13.378l83.571-39.97c2.643-1.32 5.616-1.981 8.589-1.981c2.973 0 5.945.66 8.588 1.982l83.572 39.804c5.45 2.642 9.414 7.432 10.735 13.378l20.645 89.682c1.322 5.946 0 12.057-3.798 16.847l-57.807 71.845c-3.799 4.624-9.414 7.432-15.36 7.432z" />
|
|
||||||
<path fill="#fff"
|
|
||||||
d="M128.495 7.928c2.313 0 4.625.495 6.772 1.486l83.572 39.804c4.294 2.147 7.597 6.111 8.588 10.736l20.645 89.682c1.156 4.79 0 9.745-3.138 13.543l-57.806 71.846c-2.973 3.798-7.598 5.945-12.387 5.945H82.085c-4.79 0-9.414-2.147-12.387-5.945l-57.806-71.846c-2.973-3.798-4.13-8.753-3.138-13.543l20.645-89.682c1.156-4.79 4.294-8.754 8.588-10.736L121.56 9.25c2.147-.826 4.624-1.321 6.936-1.321m0-7.763c-3.468 0-6.936.826-10.24 2.312l-83.571 39.97c-6.607 3.138-11.231 8.918-12.883 16.02L1.156 148.15c-1.651 7.102 0 14.369 4.625 20.15l57.806 71.845c4.46 5.615 11.231 8.753 18.333 8.753h92.655c7.102 0 13.874-3.138 18.333-8.753l57.807-71.846c4.624-5.615 6.276-13.047 4.624-20.15l-20.645-89.682c-1.651-7.102-6.276-12.882-12.882-16.02L138.57 2.476C135.432.991 131.964.165 128.495.165" />
|
|
||||||
<path fill="#fff"
|
|
||||||
d="M212.232 142.534c-.165 0-.165 0 0 0h-.165c-.165 0-.33 0-.33-.165c-.33 0-.66-.165-.991-.165c-1.156-.165-2.147-.33-3.138-.33c-.496 0-.991 0-1.652-.166h-.165c-3.468-.33-6.276-.66-8.919-1.486c-1.156-.496-1.486-1.156-1.817-1.817c0-.165-.165-.165-.165-.33l-2.147-.66a65.33 65.33 0 0 0-1.156-23.289a68.054 68.054 0 0 0-9.249-21.636l1.652-1.486v-.33c0-.826.165-1.652.825-2.478c1.982-1.817 4.46-3.303 7.433-5.12c.495-.33.99-.495 1.486-.826c.991-.495 1.817-.99 2.808-1.651c.165-.165.495-.33.826-.66c.165-.166.33-.166.33-.331c2.312-1.982 2.808-5.285 1.156-7.433c-.826-1.156-2.312-1.816-3.799-1.816c-1.32 0-2.477.495-3.633 1.321l-.33.33c-.33.165-.496.496-.826.661c-.826.826-1.487 1.486-2.147 2.312c-.33.33-.66.826-1.156 1.156c-2.313 2.478-4.46 4.46-6.607 5.946c-.495.33-.99.496-1.486.496c-.33 0-.661 0-.991-.166h-.33l-1.983 1.322c-2.147-2.312-4.459-4.294-6.771-6.276a65.958 65.958 0 0 0-34.519-13.709l-.165-2.147l-.33-.33c-.496-.496-1.156-.991-1.322-2.147c-.165-2.643.166-5.616.496-8.919v-.165c0-.496.165-1.156.33-1.652c.165-.99.33-1.982.496-3.138v-1.486c0-2.973-2.313-5.45-5.12-5.45c-1.322 0-2.643.66-3.634 1.651c-.99.991-1.486 2.312-1.486 3.799v1.321c0 1.156.165 2.147.495 3.138c.165.496.165.991.33 1.652v.165c.33 3.303.826 6.276.496 8.919c-.165 1.156-.826 1.651-1.321 2.147l-.33.33l-.166 2.147c-2.973.33-5.946.66-8.919 1.321c-12.717 2.808-23.948 9.25-32.701 18.498l-1.652-1.156h-.33c-.33 0-.661.165-.991.165c-.496 0-.991-.165-1.487-.495c-2.147-1.486-4.294-3.634-6.606-6.111c-.33-.33-.66-.826-1.156-1.156c-.661-.826-1.322-1.487-2.148-2.312c-.165-.166-.495-.33-.825-.661c-.165-.165-.33-.165-.33-.33a5.772 5.772 0 0 0-3.634-1.322c-1.487 0-2.973.661-3.799 1.817c-1.652 2.147-1.156 5.45 1.156 7.432c.165 0 .165.166.33.166c.33.165.496.495.826.66c.991.66 1.817 1.156 2.808 1.652c.496.165.991.495 1.487.826c2.972 1.816 5.45 3.303 7.432 5.12c.826.825.826 1.651.826 2.477v.33l1.651 1.487c-.33.495-.66.826-.826 1.321c-8.258 13.048-11.396 28.408-9.249 43.603l-2.147.66c0 .166-.165.166-.165.33c-.33.661-.826 1.322-1.817 1.817c-2.477.826-5.45 1.157-8.918 1.487h-.166c-.495 0-1.156 0-1.651.165c-.991 0-1.982.165-3.138.33c-.33 0-.66.166-.991.166c-.165 0-.33 0-.496.165c-2.973.66-4.79 3.468-4.294 6.11c.496 2.313 2.643 3.8 5.285 3.8c.496 0 .826 0 1.322-.166c.165 0 .33 0 .33-.165c.33 0 .66-.165.99-.165c1.157-.33 1.983-.66 2.974-1.156c.495-.165.99-.496 1.486-.66h.165c3.138-1.157 5.946-2.148 8.589-2.478h.33c.991 0 1.652.495 2.147.826c.165 0 .165.165.33.165l2.313-.33c3.964 12.221 11.561 23.122 21.636 31.05c2.312 1.816 4.624 3.303 7.102 4.79l-.991 2.146c0 .166.165.166.165.33c.33.661.66 1.487.33 2.643c-.99 2.478-2.477 4.955-4.294 7.763v.165c-.33.496-.66.826-.99 1.321c-.661.826-1.157 1.652-1.818 2.643c-.165.165-.33.495-.495.826c0 .165-.165.33-.165.33c-1.321 2.808-.33 5.946 2.147 7.102c.66.33 1.321.496 1.982.496c1.982 0 3.964-1.322 4.955-3.139c0-.165.165-.33.165-.33c.165-.33.33-.66.495-.826c.496-1.156.661-1.982.991-2.973l.496-1.486c1.156-3.303 1.982-5.946 3.468-8.258c.66-.991 1.487-1.156 2.147-1.487c.165 0 .165 0 .33-.165l1.157-2.147c7.267 2.808 15.195 4.294 23.122 4.294c4.79 0 9.745-.495 14.37-1.651a73.402 73.402 0 0 0 8.588-2.478l.99 1.817c.166 0 .166 0 .331.165c.826.165 1.486.496 2.147 1.487c1.321 2.312 2.312 5.12 3.468 8.258v.165l.496 1.486c.33.991.495 1.982.99 2.973c.166.33.331.496.496.826c0 .165.166.33.166.33c.99 1.982 2.972 3.139 4.954 3.139c.661 0 1.322-.166 1.982-.496c1.156-.66 2.147-1.652 2.478-2.973c.33-1.321.33-2.808-.33-4.129c0-.165-.166-.165-.166-.33c-.165-.33-.33-.66-.495-.826c-.496-.991-1.156-1.817-1.817-2.643c-.33-.495-.66-.825-.99-1.32v-.166c-1.818-2.808-3.47-5.285-4.295-7.763c-.33-1.156 0-1.816.165-2.642c0-.165.165-.165.165-.33l-.826-1.982c8.754-5.12 16.186-12.388 21.802-21.306c2.973-4.625 5.285-9.745 6.936-14.865l1.982.33c.166 0 .166-.165.33-.165c.661-.33 1.157-.825 2.148-.825h.33c2.643.33 5.45 1.32 8.589 2.477h.165c.495.165.99.495 1.486.66c.991.496 1.817.826 2.973 1.157c.33 0 .66.165.991.165c.165 0 .33 0 .495.165c.496.165.826.165 1.322.165c2.477 0 4.624-1.651 5.285-3.798c0-1.982-1.817-4.625-4.79-5.45m-76.47-8.093l-7.267 3.469l-7.267-3.469l-1.816-7.762l4.954-6.276h8.093l4.955 6.276zm43.108-17.176a52.078 52.078 0 0 1 1.156 16.68l-25.27-7.266c-2.312-.66-3.633-2.973-3.138-5.285c.165-.661.496-1.322.991-1.817l19.985-18.003c2.807 4.625 4.954 9.91 6.276 15.69m-14.204-25.6l-21.636 15.36c-1.817 1.156-4.295.825-5.781-.991c-.495-.496-.66-1.157-.826-1.817l-1.486-26.922a50.13 50.13 0 0 1 29.729 14.37M116.769 78.12c1.817-.33 3.468-.66 5.285-.99l-1.486 26.425c-.165 2.312-1.982 4.294-4.46 4.294c-.66 0-1.486-.165-1.982-.495L92.16 91.665c6.772-6.772 15.195-11.397 24.609-13.544m-32.537 23.453l19.654 17.507c1.817 1.487 1.982 4.294.496 6.111c-.496.66-1.156 1.156-1.982 1.322l-25.6 7.432c-.991-11.231 1.486-22.627 7.432-32.372m-4.46 44.759l26.262-4.46c2.147-.165 4.129 1.322 4.624 3.469c.165.99.165 1.817-.165 2.643l-10.075 24.278c-9.249-5.946-16.681-15.03-20.645-25.93m60.285 32.867c-3.799.826-7.598 1.321-11.562 1.321c-5.78 0-11.396-.99-16.68-2.642l1Line truncated
|
|
||||||
</svg></abbr>{% endif %}
|
|
||||||
<br><span style="color: aliceblue; font-size: smaller;">ver. {{ VERSION }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- <div id="nav" class="pure-u-1-3">
|
|
||||||
<a href="#" id="menuLink" class="nav-menu-button">Menu</a>
|
|
||||||
|
|
||||||
<div class="nav-inner">
|
|
||||||
<div class="pure-menu custom-restricted-width">
|
|
||||||
<ul class="pure-menu-list">
|
|
||||||
<li class="pure-menu-item"><a href="/" class="pure-menu-link">Servers</a></li>
|
|
||||||
<li class="pure-menu-item"><a href="/clients" class="pure-menu-link">Clients</a></li>
|
|
||||||
<li class="pure-menu-item"><a href="/sync" class="pure-menu-link">Sync status</a></li>
|
|
||||||
</ul>
|
|
||||||
<div style="padding-left: 20%;padding-top: 50px;">
|
|
||||||
{% if K8S_NAMESPACE %}<abbr title="Works on K8S"><svg xmlns="http://www.w3.org/2000/svg" width="25%"
|
|
||||||
viewBox="0 0 256 249">
|
|
||||||
<path fill="#326de6"
|
|
||||||
d="M82.085 244.934c-5.946 0-11.561-2.642-15.36-7.432L8.92 165.657c-3.799-4.79-5.285-10.9-3.799-16.847l20.645-89.682c1.321-5.946 5.285-10.736 10.736-13.378l83.571-39.97c2.643-1.32 5.616-1.981 8.589-1.981c2.973 0 5.945.66 8.588 1.982l83.572 39.804c5.45 2.642 9.414 7.432 10.735 13.378l20.645 89.682c1.322 5.946 0 12.057-3.798 16.847l-57.807 71.845c-3.799 4.624-9.414 7.432-15.36 7.432z" />
|
|
||||||
<path fill="#fff"
|
|
||||||
d="M128.495 7.928c2.313 0 4.625.495 6.772 1.486l83.572 39.804c4.294 2.147 7.597 6.111 8.588 10.736l20.645 89.682c1.156 4.79 0 9.745-3.138 13.543l-57.806 71.846c-2.973 3.798-7.598 5.945-12.387 5.945H82.085c-4.79 0-9.414-2.147-12.387-5.945l-57.806-71.846c-2.973-3.798-4.13-8.753-3.138-13.543l20.645-89.682c1.156-4.79 4.294-8.754 8.588-10.736L121.56 9.25c2.147-.826 4.624-1.321 6.936-1.321m0-7.763c-3.468 0-6.936.826-10.24 2.312l-83.571 39.97c-6.607 3.138-11.231 8.918-12.883 16.02L1.156 148.15c-1.651 7.102 0 14.369 4.625 20.15l57.806 71.845c4.46 5.615 11.231 8.753 18.333 8.753h92.655c7.102 0 13.874-3.138 18.333-8.753l57.807-71.846c4.624-5.615 6.276-13.047 4.624-20.15l-20.645-89.682c-1.651-7.102-6.276-12.882-12.882-16.02L138.57 2.476C135.432.991 131.964.165 128.495.165" />
|
|
||||||
<path fill="#fff"
|
|
||||||
d="M212.232 142.534c-.165 0-.165 0 0 0h-.165c-.165 0-.33 0-.33-.165c-.33 0-.66-.165-.991-.165c-1.156-.165-2.147-.33-3.138-.33c-.496 0-.991 0-1.652-.166h-.165c-3.468-.33-6.276-.66-8.919-1.486c-1.156-.496-1.486-1.156-1.817-1.817c0-.165-.165-.165-.165-.33l-2.147-.66a65.33 65.33 0 0 0-1.156-23.289a68.054 68.054 0 0 0-9.249-21.636l1.652-1.486v-.33c0-.826.165-1.652.825-2.478c1.982-1.817 4.46-3.303 7.433-5.12c.495-.33.99-.495 1.486-.826c.991-.495 1.817-.99 2.808-1.651c.165-.165.495-.33.826-.66c.165-.166.33-.166.33-.331c2.312-1.982 2.808-5.285 1.156-7.433c-.826-1.156-2.312-1.816-3.799-1.816c-1.32 0-2.477.495-3.633 1.321l-.33.33c-.33.165-.496.496-.826.661c-.826.826-1.487 1.486-2.147 2.312c-.33.33-.66.826-1.156 1.156c-2.313 2.478-4.46 4.46-6.607 5.946c-.495.33-.99.496-1.486.496c-.33 0-.661 0-.991-.166h-.33l-1.983 1.322c-2.147-2.312-4.459-4.294-6.771-6.276a65.958 65.958 0 0 0-34.519-13.709l-.165-2.147l-.33-.33c-.496-.496-1.156-.991-1.322-2.147c-.165-2.643.166-5.616.496-8.919v-.165c0-.496.165-1.156.33-1.652c.165-.99.33-1.982.496-3.138v-1.486c0-2.973-2.313-5.45-5.12-5.45c-1.322 0-2.643.66-3.634 1.651c-.99.991-1.486 2.312-1.486 3.799v1.321c0 1.156.165 2.147.495 3.138c.165.496.165.991.33 1.652v.165c.33 3.303.826 6.276.496 8.919c-.165 1.156-.826 1.651-1.321 2.147l-.33.33l-.166 2.147c-2.973.33-5.946.66-8.919 1.321c-12.717 2.808-23.948 9.25-32.701 18.498l-1.652-1.156h-.33c-.33 0-.661.165-.991.165c-.496 0-.991-.165-1.487-.495c-2.147-1.486-4.294-3.634-6.606-6.111c-.33-.33-.66-.826-1.156-1.156c-.661-.826-1.322-1.487-2.148-2.312c-.165-.166-.495-.33-.825-.661c-.165-.165-.33-.165-.33-.33a5.772 5.772 0 0 0-3.634-1.322c-1.487 0-2.973.661-3.799 1.817c-1.652 2.147-1.156 5.45 1.156 7.432c.165 0 .165.166.33.166c.33.165.496.495.826.66c.991.66 1.817 1.156 2.808 1.652c.496.165.991.495 1.487.826c2.972 1.816 5.45 3.303 7.432 5.12c.826.825.826 1.651.826 2.477v.33l1.651 1.487c-.33.495-.66.826-.826 1.321c-8.258 13.048-11.396 28.408-9.249 43.603l-2.147.66c0 .166-.165.166-.165.33c-.33.661-.826 1.322-1.817 1.817c-2.477.826-5.45 1.157-8.918 1.487h-.166c-.495 0-1.156 0-1.651.165c-.991 0-1.982.165-3.138.33c-.33 0-.66.166-.991.166c-.165 0-.33 0-.496.165c-2.973.66-4.79 3.468-4.294 6.11c.496 2.313 2.643 3.8 5.285 3.8c.496 0 .826 0 1.322-.166c.165 0 .33 0 .33-.165c.33 0 .66-.165.99-.165c1.157-.33 1.983-.66 2.974-1.156c.495-.165.99-.496 1.486-.66h.165c3.138-1.157 5.946-2.148 8.589-2.478h.33c.991 0 1.652.495 2.147.826c.165 0 .165.165.33.165l2.313-.33c3.964 12.221 11.561 23.122 21.636 31.05c2.312 1.816 4.624 3.303 7.102 4.79l-.991 2.146c0 .166.165.166.165.33c.33.661.66 1.487.33 2.643c-.99 2.478-2.477 4.955-4.294 7.763v.165c-.33.496-.66.826-.99 1.321c-.661.826-1.157 1.652-1.818 2.643c-.165.165-.33.495-.495.826c0 .165-.165.33-.165.33c-1.321 2.808-.33 5.946 2.147 7.102c.66.33 1.321.496 1.982.496c1.982 0 3.964-1.322 4.955-3.139c0-.165.165-.33.165-.33c.165-.33.33-.66.495-.826c.496-1.156.661-1.982.991-2.973l.496-1.486c1.156-3.303 1.982-5.946 3.468-8.258c.66-.991 1.487-1.156 2.147-1.487c.165 0 .165 0 .33-.165l1.157-2.147c7.267 2.808 15.195 4.294 23.122 4.294c4.79 0 9.745-.495 14.37-1.651a73.402 73.402 0 0 0 8.588-2.478l.99 1.817c.166 0 .166 0 .331.165c.826.165 1.486.496 2.147 1.487c1.321 2.312 2.312 5.12 3.468 8.258v.165l.496 1.486c.33.991.495 1.982.99 2.973c.166.33.331.496.496.826c0 .165.166.33.166.33c.99 1.982 2.972 3.139 4.954 3.139c.661 0 1.322-.166 1.982-.496c1.156-.66 2.147-1.652 2.478-2.973c.33-1.321.33-2.808-.33-4.129c0-.165-.166-.165-.166-.33c-.165-.33-.33-.66-.495-.826c-.496-.991-1.156-1.817-1.817-2.643c-.33-.495-.66-.825-.99-1.32v-.166c-1.818-2.808-3.47-5.285-4.295-7.763c-.33-1.156 0-1.816.165-2.642c0-.165.165-.165.165-.33l-.826-1.982c8.754-5.12 16.186-12.388 21.802-21.306c2.973-4.625 5.285-9.745 6.936-14.865l1.982.33c.166 0 .166-.165.33-.165c.661-.33 1.157-.825 2.148-.825h.33c2.643.33 5.45 1.32 8.589 2.477h.165c.495.165.99.495 1.486.66c.991.496 1.817.826 2.973 1.157c.33 0 .66.165.991.165c.165 0 .33 0 .495.165c.496.165.826.165 1.322.165c2.477 0 4.624-1.651 5.285-3.798c0-1.982-1.817-4.625-4.79-5.45m-76.47-8.093l-7.267 3.469l-7.267-3.469l-1.816-7.762l4.954-6.276h8.093l4.955 6.276zm43.108-17.176a52.078 52.078 0 0 1 1.156 16.68l-25.27-7.266c-2.312-.66-3.633-2.973-3.138-5.285c.165-.661.496-1.322.991-1.817l19.985-18.003c2.807 4.625 4.954 9.91 6.276 15.69m-14.204-25.6l-21.636 15.36c-1.817 1.156-4.295.825-5.781-.991c-.495-.496-.66-1.157-.826-1.817l-1.486-26.922a50.13 50.13 0 0 1 29.729 14.37M116.769 78.12c1.817-.33 3.468-.66 5.285-.99l-1.486 26.425c-.165 2.312-1.982 4.294-4.46 4.294c-.66 0-1.486-.165-1.982-.495L92.16 91.665c6.772-6.772 15.195-11.397 24.609-13.544m-32.537 23.453l19.654 17.507c1.817 1.487 1.982 4.294.496 6.111c-.496.66-1.156 1.156-1.982 1.322l-25.6 7.432c-.991-11.231 1.486-22.627 7.432-32.372m-4.46 44.759l26.262-4.46c2.147-.165 4.129 1.322 4.624 3.469c.165.99.165 1.817-.165 2.643l-10.075 24.278c-9.249-5.946-16.681-15.03-20.645-25.93m60.285 32.867c-3.799.826-7.598 1.321-11.562 1.321c-5.78 0-11.396-.99-16.68-2.642l1Line truncated
|
|
||||||
</svg></abbr>{% endif %}
|
|
||||||
<br><span style="color: aliceblue; font-size: smaller;">ver. {{ VERSION }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<!-- Script to make the Menu link work -->
|
|
||||||
<!-- Just stripped down version of the js/ui.js script for the side-menu layout -->
|
|
||||||
<script>
|
|
||||||
function getElements() {
|
|
||||||
return {
|
|
||||||
menu: document.getElementById('nav'),
|
|
||||||
menuLink: document.getElementById('menuLink')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleClass(element, className) {
|
|
||||||
var classes = element.className.split(/\s+/);
|
|
||||||
var length = classes.length;
|
|
||||||
var i = 0;
|
|
||||||
|
|
||||||
for (; i < length; i++) {
|
|
||||||
if (classes[i] === className) {
|
|
||||||
classes.splice(i, 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The className is not found
|
|
||||||
if (length === classes.length) {
|
|
||||||
classes.push(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.className = classes.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMenu() {
|
|
||||||
var active = 'active';
|
|
||||||
var elements = getElements();
|
|
||||||
|
|
||||||
toggleClass(elements.menu, active);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEvent(e) {
|
|
||||||
var elements = getElements();
|
|
||||||
|
|
||||||
if (e.target.id === elements.menuLink.id) {
|
|
||||||
toggleMenu();
|
|
||||||
e.preventDefault();
|
|
||||||
} else if (elements.menu.className.indexOf('active') !== -1) {
|
|
||||||
toggleMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
document.addEventListener('click', handleEvent);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
// Поиск по скписк в подменю
|
|
||||||
// entety-menu-search
|
|
||||||
const searchBox = document.getElementById('entety-menu-search')
|
|
||||||
searchBox.addEventListener('change', (event)=>{
|
|
||||||
const text = event.target.value
|
|
||||||
const child = Array.from(document.querySelector('[data-search-box]').children)
|
|
||||||
|
|
||||||
child.forEach((element)=>{
|
|
||||||
if(text.length === 0){
|
|
||||||
element.classList.remove('hidden')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const elementSearchData = element.querySelector('[data-search]').textContent
|
|
||||||
const isEqual = elementSearchData.toLowerCase().includes(text.toLowerCase())
|
|
||||||
if(isEqual){
|
|
||||||
element.classList.remove('hidden')
|
|
||||||
} else {
|
|
||||||
element.classList.add('hidden')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
{% if nt %}
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" class="alertCheckbox" autocomplete="off" />
|
|
||||||
<div class="alert {% if nl == 'error' %}error{% else %}success{% endif %}">
|
|
||||||
<span class="alertText">{{nt}}
|
|
||||||
<br class="clear" /></span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
{% endif %}
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
Whitespace-only changes.
+100
@@ -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>")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class VPN(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'vpn'
|
||||||
@@ -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']
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .generic import Server
|
||||||
|
from .outline import OutlineServer, OutlineServerAdmin
|
||||||
|
from .wireguard import WireguardServer, WireguardServerAdmin
|
||||||
|
from .urls import urlpatterns
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from vpn.views import shadowsocks
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('ss/<str:hash_value>/', shadowsocks, name='shadowsocks'),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
@@ -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