mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2026-11-08 16:58:50 +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
|
||||
__pycache__/
|
||||
sync.log
|
||||
main.py
|
||||
.idea/*
|
||||
.vscode/*
|
||||
db.sqlite3
|
||||
debug.log
|
||||
*.swp
|
||||
*.swo
|
||||
*.swn
|
||||
*.pyc
|
||||
staticfiles/
|
||||
*.__pycache__.*
|
||||
vpn/migrations/
|
||||
Executable → Regular
+5
-8
@@ -1,13 +1,10 @@
|
||||
FROM python:3-alpine
|
||||
FROM python:3
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY static static
|
||||
COPY templates templates
|
||||
COPY *.py .
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["python", "main.py"]
|
||||
COPY . .
|
||||
|
||||
CMD [ "python", "./manage.py", "runserver", "0.0.0.0:8000" ]
|
||||
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
|
||||
kubernetes
|
||||
PyYAML>=6.0.1
|
||||
Flask>=2.3.3
|
||||
flask-cors
|
||||
bcrypt
|
||||
django-environ==0.11.2
|
||||
Django==5.1.2
|
||||
celery==5.4.0
|
||||
django-jazzmin==3.0.1
|
||||
django-polymorphic==3.1.0
|
||||
django-cors-headers==4.5.0
|
||||
requests==2.32.3
|
||||
outline-vpn-api==6.3.0
|
||||
Redis==5.1.1
|
||||
whitenoise==6.7.0
|
||||
psycopg2-binary==2.9.10
|
||||
setuptools==75.2.0
|
||||
shortuuid==1.0.13
|
||||
django-celery-results==2.5.1
|
||||
@@ -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