Files
kaskad/webui/app.py
andrey271192 209caa81a8 Initial release of Kaskad
Каскадная маршрутизация русских сайтов через свой набор RU-серверов
с failover, Telegram-ботом и веб-интерфейсом.

- bin/        — failover-script, route/domain helpers, ams/ru bootstrap
- bot/        — Telegram-бот (Python, long-poll)
- webui/      — Flask single-page dashboard
- docs/       — установка, архитектура, API бота и WebUI
- examples/   — шаблоны конфигов
2026-05-02 09:13:23 +03:00

537 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Kaskad Web UI - dashboard для управления RU/ам. серверами, доменами и IP.
Шарит логику с TG-ботом: читает /etc/wireguard/ru-servers.json и шеллит
те же скрипты (ru-set.sh, ru-routes.sh, ru-domains.py).
"""
import base64, functools, json, os, re, shlex, socket, subprocess
from pathlib import Path
from flask import Flask, jsonify, render_template, request, Response
SERVERS_JSON = Path(os.environ.get("KASKAD_SERVERS_JSON", "/etc/wireguard/ru-servers.json"))
LOCAL_HOST = os.environ.get("LOCAL_HOST", "ams1")
LOCAL_IP = os.environ.get("LOCAL_IP", "127.0.0.1")
BOT_KEY = os.environ.get("KASKAD_SSH_KEY", "/root/.ssh/id_ed25519")
WEB_USER = os.environ.get("KASKAD_WEB_USER", "admin")
WEB_PASS = os.environ.get("KASKAD_WEB_PASS", "") # обязательно задать в проде!
app = Flask(__name__, template_folder="templates", static_folder="static")
CIDR_RX = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2})?)\b")
# --- auth ---
def require_auth(fn):
@functools.wraps(fn)
def w(*a, **kw):
if not WEB_PASS:
return Response("KASKAD_WEB_PASS не задан", 500)
auth = request.authorization
if not auth or auth.username != WEB_USER or auth.password != WEB_PASS:
return Response("Auth required", 401, {"WWW-Authenticate": 'Basic realm="kaskad"'})
return fn(*a, **kw)
return w
# --- shell + ssh ---
def shell(cmd, timeout=30, input=None):
r = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=timeout, input=input)
return r.stdout.strip(), r.returncode, r.stderr.strip()
def ssh_run(host, cmd, timeout=15, port=22, user="root"):
if host == LOCAL_IP:
out, rc, _ = shell(cmd, timeout=timeout)
return out, rc
args = ["ssh", "-i", BOT_KEY, "-p", str(port),
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes",
f"{user}@{host}", cmd]
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return r.stdout.strip(), r.returncode
def ssh_pw(host, port, user, password, cmd, timeout=240):
args = ["sshpass", "-p", password, "ssh",
"-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=15",
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
"-p", str(port), f"{user}@{host}", cmd]
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return r.stdout, r.returncode, r.stderr
def scp_pw(host, port, user, password, files, dest, timeout=60):
args = ["sshpass", "-p", password, "scp", "-P", str(port),
"-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=15",
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no"]
if isinstance(files, str): files = [files]
args += files + [f"{user}@{host}:{dest}"]
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return r.returncode == 0, (r.stderr or r.stdout).strip()
def scp_key(host, port, files, dest, timeout=60):
args = ["scp", "-P", str(port), "-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=15", "-i", BOT_KEY, "-o", "BatchMode=yes"]
if isinstance(files, str): files = [files]
args += files + [f"root@{host}:{dest}"]
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return r.returncode == 0, (r.stderr or r.stdout).strip()
# --- config ---
def load_data():
try:
return json.loads(SERVERS_JSON.read_text())
except Exception:
return {"servers": [], "ams_servers": []}
def save_and_distribute(data):
js = json.dumps(data, indent=2, ensure_ascii=False)
SERVERS_JSON.write_text(js)
fail = []
for a in data.get("ams_servers", []):
if a.get("is_local"): continue
proc = subprocess.run(
["ssh", "-i", BOT_KEY, "-p", str(a.get("ssh_port", 22)),
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes",
f"root@{a['host']}", f"cat > {SERVERS_JSON} && chmod 600 {SERVERS_JSON}"],
input=js, text=True, capture_output=True, timeout=10)
if proc.returncode != 0:
fail.append(f"{a['id']}: {proc.stderr.strip()}")
return (len(fail) == 0), ("; ".join(fail) if fail else "OK")
def ams_list_data(): return load_data().get("ams_servers", [])
def ru_list_data(): return sorted(load_data().get("servers", []), key=lambda x: x["priority"])
def ssh_ams(a, cmd, timeout=15):
if a.get("is_local") or a["host"] == LOCAL_IP:
return shell(cmd, timeout=timeout)[:2]
return ssh_run(a["host"], cmd, timeout=timeout, port=a.get("ssh_port", 22))
def ssh_ru(s, cmd, timeout=15):
return ssh_run(s["host"], cmd, timeout=timeout,
port=s.get("ssh_port", 22), user=s.get("ssh_user", "root"))
# --- queries ---
QUERY_CMD = (
"ep=$(grep ^Endpoint /etc/wireguard/ru.conf | awk '{print $3}'); "
"hs=$(wg show ru latest-handshakes 2>/dev/null | head -1 | awk '{print $2}'); "
"now=$(date +%s); age=$((now-${hs:-0})); "
"[ \"${hs:-0}\" -eq 0 ] && age=999999; "
"echo \"$ep|$age\""
)
def label_for(ep):
for s in ru_list_data():
if s["endpoint"] == ep or s["host"] in ep:
return f"{s['id']} ({s['label']})"
return ep
def get_status():
out = []
for a in ams_list_data():
line = {"id": a["id"], "host": a["host"], "tunnel_ip": a["tunnel_ip"]}
o, rc = ssh_ams(a, QUERY_CMD)
if rc != 0:
line["state"] = "unreachable"
else:
try:
ep, age = o.split("|")
line["endpoint"] = ep
line["label"] = label_for(ep)
line["handshake_age"] = int(age) if int(age) < 99999 else None
line["state"] = "ok"
except Exception:
line["state"] = "parse_error"
line["raw"] = o
out.append(line)
return out
# --- API ---
@app.route("/api/state")
@require_auth
def api_state():
data = load_data()
extra_ips = []
out, _, _ = shell("/usr/local/bin/ru-routes.sh list")
if out and out.strip() != "(пусто)":
extra_ips = [l.strip() for l in out.splitlines() if l.strip()]
base_aips, _, _ = shell("cat /etc/wireguard/ru-base.aips 2>/dev/null || true")
base = [x.strip() for x in (base_aips or "").split(",") if x.strip()]
domains = {}
out, rc, _ = shell("/usr/local/bin/ru-domains.py list")
if rc == 0 and out.strip() != "(пусто)":
for line in out.splitlines():
if ":" in line:
k, v = line.split(":", 1)
try: domains[k.strip()] = int(v.strip().split()[0])
except: pass
return jsonify({
"ru_servers": ru_list_data(),
"ams_servers": ams_list_data(),
"status": get_status(),
"extra_ips": extra_ips,
"base_ips": base,
"domains": domains,
})
@app.route("/api/use", methods=["POST"])
@require_auth
def api_use():
sid = (request.json or {}).get("id", "").strip()
if not sid: return jsonify(error="id required"), 400
if not any(s["id"] == sid for s in ru_list_data()):
return jsonify(error=f"unknown server: {sid}"), 404
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, f"/usr/local/bin/ru-set.sh {shlex.quote(sid)}")
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/api/server", methods=["POST"])
@require_auth
def api_server_add():
body = request.json or {}
host = body.get("host"); user = body.get("user", "root"); ssh_port = body.get("ssh_port", 22)
sid = body.get("id"); priority = int(body.get("priority", 99))
label = body.get("label", host); listen_port = int(body.get("listen_port", 1939))
probe_port = int(body.get("probe_port", ssh_port)); password = body.get("password")
if not (host and sid): return jsonify(error="host и id обязательны"), 400
data = load_data()
if any(s["id"] == sid for s in data["servers"]):
return jsonify(error=f"id '{sid}' уже есть"), 409
bot_key_pub, _, _ = shell(f"cat {BOT_KEY}.pub")
bot_key_b64 = base64.b64encode(bot_key_pub.encode()).decode()
helper = "/usr/local/bin/add-ru-helper.sh"
if not Path(helper).exists():
return jsonify(error=f"нет {helper}"), 500
if password:
ok, err = scp_pw(host, ssh_port, user, password, helper, "/tmp/add-ru-helper.sh")
else:
ok, err = scp_key(host, ssh_port, helper, "/tmp/add-ru-helper.sh")
if not ok: return jsonify(error=f"scp: {err[:500]}"), 500
args = [bot_key_b64, str(listen_port)]
for a in sorted(data.get("ams_servers", []), key=lambda x: x["tunnel_ip"]):
args += [a["pubkey"], a["tunnel_ip"]]
arg_str = " ".join(shlex.quote(x) for x in args)
sudo_p = ""
if user != "root":
sudo_p = f"echo {shlex.quote(password or '')} | sudo -S -p '' " if password else "sudo -n "
cmd = f"chmod +x /tmp/add-ru-helper.sh && {sudo_p}bash /tmp/add-ru-helper.sh {arg_str}"
if password:
out, rc, err = ssh_pw(host, ssh_port, user, password, cmd, timeout=240)
else:
out, rc = ssh_run(host, cmd, timeout=240, port=ssh_port, user=user); err = ""
full = (out or "") + (err or "")
if "----RESULT----" not in full:
return jsonify(error=f"helper не отработал: {full[-1000:]}"), 500
res = {}
in_b = False
for line in full.splitlines():
if line == "----RESULT----": in_b = True; continue
if line == "----END----": in_b = False; continue
if in_b and "=" in line:
k, v = line.split("=", 1); res[k] = v.strip()
pubkey = res.get("PUBKEY")
if not pubkey: return jsonify(error="pubkey не получен"), 500
new = {
"id": sid, "host": host, "endpoint": f"{host}:{listen_port}",
"pubkey": pubkey, "probe_port": probe_port, "priority": priority, "label": label,
"ssh_user": "root", "ssh_port": int(ssh_port),
"wg_iface": res.get("IFACE", "ens18"),
}
data["servers"].append(new)
ok, err = save_and_distribute(data)
if not ok: return jsonify(error=f"sync: {err}"), 500
return jsonify(server=new, public_ip=res.get("PUBLIC_IP"))
@app.route("/api/server/<sid>", methods=["DELETE"])
@require_auth
def api_server_remove(sid):
data = load_data()
before = len(data["servers"])
data["servers"] = [s for s in data["servers"] if s["id"] != sid and s["host"] != sid]
if len(data["servers"]) == before: return jsonify(error="не найден"), 404
if not data["servers"]: return jsonify(error="последний сервер, отказ"), 400
save_and_distribute(data)
return jsonify(ok=True)
@app.route("/api/ams", methods=["POST"])
@require_auth
def api_ams_add():
body = request.json or {}
host = body.get("host"); user = body.get("user", "root")
ssh_port = int(body.get("ssh_port", 22)); sid = body.get("id")
xray_iface = body.get("xray_iface", "amn0"); password = body.get("password")
requested_tunnel = body.get("tunnel_ip")
if not (host and sid): return jsonify(error="host и id обязательны"), 400
data = load_data()
if any(a["id"] == sid or a["host"] == host for a in data.get("ams_servers", [])):
return jsonify(error=f"id или host уже есть"), 409
used = {a["tunnel_ip"] for a in data.get("ams_servers", [])} | {"10.0.0.1"}
if requested_tunnel:
if requested_tunnel in used: return jsonify(error=f"{requested_tunnel} занят"), 409
tunnel_ip = requested_tunnel
else:
tunnel_ip = next((f"10.0.0.{i}" for i in range(2, 255) if f"10.0.0.{i}" not in used), None)
if not tunnel_ip: return jsonify(error="нет свободных tunnel IP"), 500
rus = ru_list_data()
if not rus: return jsonify(error="нет RU-серверов в конфиге"), 500
primary = rus[0]
bot_key_pub, _, _ = shell(f"cat {BOT_KEY}.pub")
bot_key_b64 = base64.b64encode(bot_key_pub.encode()).decode()
helper_local = "/usr/local/bin/add-ams-helper.sh"
if password:
ok, err = scp_pw(host, ssh_port, user, password, helper_local, "/tmp/add-ams-helper.sh")
else:
ok, err = scp_key(host, ssh_port, helper_local, "/tmp/add-ams-helper.sh")
if not ok: return jsonify(error=f"scp helper: {err[:500]}"), 500
sudo_p = ""
if user != "root":
sudo_p = f"echo {shlex.quote(password or '')} | sudo -S -p '' " if password else "sudo -n "
helper_cmd = f"chmod +x /tmp/add-ams-helper.sh && {sudo_p}bash /tmp/add-ams-helper.sh {shlex.quote(bot_key_b64)}"
if password:
out, rc, err = ssh_pw(host, ssh_port, user, password, helper_cmd, timeout=180)
else:
out, rc = ssh_run(host, helper_cmd, timeout=180, port=ssh_port, user=user); err = ""
full = (out or "") + (err or "")
if "----RESULT----" not in full:
return jsonify(error=f"helper failed: {full[-1000:]}"), 500
out_pk, rc = ssh_run(host, "test -f /etc/wireguard/ru_private.key || (umask 077 && wg genkey | tee /etc/wireguard/ru_private.key | wg pubkey > /etc/wireguard/ru_public.key); cat /etc/wireguard/ru_public.key", timeout=15, port=ssh_port)
if rc != 0: return jsonify(error=f"key gen: {out_pk}"), 500
new_pubkey = out_pk.strip()
files = ["/usr/local/bin/ru-failover.py", "/usr/local/bin/ru-set.sh",
"/usr/local/bin/ru-routes.sh", "/usr/local/bin/ru-domains.py",
"/etc/wireguard/notify.env", str(SERVERS_JSON)]
ok, err = scp_key(host, ssh_port, files, "/tmp/", timeout=30)
if not ok: return jsonify(error=f"scp scripts: {err[:500]}"), 500
sga1_conf, _, _ = shell("cat /etc/wireguard/ru.conf")
sga1_base, _, _ = shell("cat /etc/wireguard/ru-base.aips 2>/dev/null || true")
new_conf = re.sub(r'(?m)^Address *=.*$', f'Address = {tunnel_ip}/32', sga1_conf, count=1)
new_conf = re.sub(r'(?m)^PublicKey *=.*$', f'PublicKey = {primary["pubkey"]}', new_conf, count=1)
new_conf = re.sub(r'(?m)^Endpoint *=.*$', f'Endpoint = {primary["endpoint"]}', new_conf, count=1)
if xray_iface != "amn0":
new_conf = new_conf.replace("amn0", xray_iface)
proc = subprocess.run(
["ssh", "-i", BOT_KEY, "-p", str(ssh_port),
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes",
f"root@{host}", "cat > /etc/wireguard/ru.conf && chmod 600 /etc/wireguard/ru.conf"],
input=new_conf, text=True, capture_output=True, timeout=15)
if proc.returncode != 0:
return jsonify(error=f"write ru.conf: {proc.stderr.strip()}"), 500
if sga1_base:
subprocess.run(
["ssh", "-i", BOT_KEY, "-p", str(ssh_port),
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes",
f"root@{host}", "cat > /etc/wireguard/ru-base.aips && chmod 600 /etc/wireguard/ru-base.aips"],
input=sga1_base, text=True, capture_output=True, timeout=10)
install_cmd = """
install -m 755 /tmp/ru-failover.py /usr/local/bin/ru-failover.py
install -m 755 /tmp/ru-set.sh /usr/local/bin/ru-set.sh
install -m 755 /tmp/ru-routes.sh /usr/local/bin/ru-routes.sh
install -m 755 /tmp/ru-domains.py /usr/local/bin/ru-domains.py
install -m 600 /tmp/notify.env /etc/wireguard/notify.env
install -m 600 /tmp/ru-servers.json /etc/wireguard/ru-servers.json
touch /etc/wireguard/ru-extra.list && chmod 600 /etc/wireguard/ru-extra.list
touch /etc/wireguard/ru-domains.json && chmod 600 /etc/wireguard/ru-domains.json
[ -s /etc/wireguard/ru-domains.json ] || echo '{}' > /etc/wireguard/ru-domains.json
( crontab -l 2>/dev/null | grep -v 'ru-failover\\|ru-domains' ; echo '* * * * * /usr/local/bin/ru-failover.py' ; echo '17 */6 * * * /usr/local/bin/ru-domains.py refresh >> /var/log/ru-domains.log 2>&1' ) | crontab -
wg-quick down ru 2>/dev/null || true
wg-quick up ru 2>&1 | tail -5
systemctl enable wg-quick@ru 2>&1 | tail -1
"""
out_inst, rc = ssh_run(host, install_cmd, timeout=60, port=ssh_port)
if rc != 0: return jsonify(error=f"install: {out_inst[-500:]}"), 500
peer_block = f"\n[Peer]\n# {sid}\nPublicKey = {new_pubkey}\nAllowedIPs = {tunnel_ip}/32\n"
peer_results = []
for ru in rus:
cmd = (f"if grep -qF '{new_pubkey}' /etc/wireguard/wg_ru.conf; then echo 'already'; "
f"else printf '%s' {shlex.quote(peer_block)} >> /etc/wireguard/wg_ru.conf; fi; "
f"wg syncconf wg_ru <(wg-quick strip wg_ru) 2>&1")
out_pr, rc_pr = ssh_ru(ru, cmd, timeout=20)
peer_results.append({"ru": ru["id"], "ok": rc_pr == 0, "msg": (out_pr.splitlines()[-1] if out_pr else "")})
data["ams_servers"].append({
"id": sid, "host": host, "ssh_port": int(ssh_port),
"tunnel_ip": tunnel_ip, "pubkey": new_pubkey, "xray_iface": xray_iface,
})
save_and_distribute(data)
return jsonify(ams={"id": sid, "host": host, "tunnel_ip": tunnel_ip, "pubkey": new_pubkey},
peers=peer_results)
@app.route("/api/ams/<sid>", methods=["DELETE"])
@require_auth
def api_ams_remove(sid):
data = load_data()
target = next((a for a in data.get("ams_servers", []) if a["id"] == sid or a["host"] == sid), None)
if not target: return jsonify(error="не найден"), 404
if target.get("is_local"): return jsonify(error="нельзя удалить local"), 400
rus = ru_list_data()
peer_results = []
for ru in rus:
cmd = f"""
python3 - <<'PY'
import pathlib, re
p = pathlib.Path('/etc/wireguard/wg_ru.conf')
t = p.read_text()
parts = re.split(r'(\\[Peer\\])', t)
result = parts[0]
i = 1
while i < len(parts):
block = parts[i] + (parts[i+1] if i+1 < len(parts) else '')
if {target['pubkey']!r} in block:
i += 2; continue
result += block
i += 2
p.write_text(result)
PY
wg syncconf wg_ru <(wg-quick strip wg_ru) 2>&1
"""
out_pr, rc_pr = ssh_ru(ru, cmd, timeout=20)
peer_results.append({"ru": ru["id"], "ok": rc_pr == 0})
data["ams_servers"] = [a for a in data["ams_servers"] if a["id"] != target["id"]]
save_and_distribute(data)
return jsonify(removed=target["id"], peers=peer_results)
@app.route("/api/domains", methods=["POST"])
@require_auth
def api_domains_add():
domains = (request.json or {}).get("domains", [])
if not domains: return jsonify(error="domains обязателен"), 400
args = " ".join(shlex.quote(d) for d in domains)
cmd = f"/usr/local/bin/ru-domains.py add {args}"
timeout = max(60, len(domains) * 5)
import concurrent.futures as cf
results = []
def run(a):
return a["id"], ssh_ams(a, cmd, timeout=timeout)
with cf.ThreadPoolExecutor(max_workers=8) as ex:
for fut in cf.as_completed([ex.submit(run, a) for a in ams_list_data()]):
try:
aid, (out, rc) = fut.result()
ok = sum(1 for l in (out or "").splitlines() if l.startswith(""))
bad = sum(1 for l in (out or "").splitlines() if l.startswith(""))
results.append({"ams": aid, "ok": rc == 0, "added": ok, "failed": bad, "raw": out})
except Exception as e:
results.append({"ams": "?", "ok": False, "msg": str(e)})
return jsonify(results=results)
@app.route("/api/domains", methods=["DELETE"])
@require_auth
def api_domains_remove():
domains = (request.json or {}).get("domains", [])
if not domains: return jsonify(error="domains обязателен"), 400
args = " ".join(shlex.quote(d) for d in domains)
cmd = f"/usr/local/bin/ru-domains.py remove {args}"
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, cmd, timeout=30)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/api/domains/refresh", methods=["POST"])
@require_auth
def api_domains_refresh():
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, "/usr/local/bin/ru-domains.py refresh", timeout=600)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/api/ips", methods=["POST"])
@require_auth
def api_ips_add():
raw = (request.json or {}).get("ips", [])
if isinstance(raw, str):
ips = CIDR_RX.findall(raw)
else:
ips = []
for x in raw: ips += CIDR_RX.findall(x)
if not ips: return jsonify(error="не нашёл IP/CIDR"), 400
args = " ".join(shlex.quote(i) for i in ips)
cmd = f"/usr/local/bin/ru-routes.sh add {args}"
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, cmd, timeout=20)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(parsed=ips, results=results)
@app.route("/api/ips", methods=["DELETE"])
@require_auth
def api_ips_remove():
raw = (request.json or {}).get("ips", [])
if isinstance(raw, str):
ips = CIDR_RX.findall(raw)
else:
ips = []
for x in raw: ips += CIDR_RX.findall(x)
if not ips: return jsonify(error="не нашёл IP/CIDR"), 400
args = " ".join(shlex.quote(i) for i in ips)
cmd = f"/usr/local/bin/ru-routes.sh remove {args}"
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, cmd, timeout=20)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/api/ips/clear", methods=["POST"])
@require_auth
def api_ips_clear():
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, "/usr/local/bin/ru-routes.sh clear", timeout=20)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/")
@require_auth
def index():
return render_template("index.html")
if __name__ == "__main__":
app.run(host=os.environ.get("KASKAD_HOST", "0.0.0.0"),
port=int(os.environ.get("KASKAD_PORT", "8088")),
debug=False)