From 5b29e96d6cb9858d5cdf4b3ce9f9cddfaafd1fcc Mon Sep 17 00:00:00 2001 From: def Date: Sun, 8 Mar 2026 01:09:33 +0000 Subject: [PATCH] db-admin --- VERSION | 1 + app/app.py | 686 ++++++++++++++++++++++++++++++++ app/config.json | 21 + app/sslcheck.py | 114 ++++++ app/static/favicon.ico | Bin 0 -> 13854 bytes app/static/style.css | 99 +++++ app/static/ufo-box.png | Bin 0 -> 13854 bytes app/templates/base.html | 83 ++++ app/templates/bulk_hosts.html | 79 ++++ app/templates/edit_host.html | 114 ++++++ app/templates/health.html | 21 + app/templates/hosts.html | 113 ++++++ app/templates/import_hosts.html | 23 ++ bin/backup_now.sh | 12 + db-admin-sslcheck.service | 12 + db-admin-sslcheck.timer | 10 + db-admin.service | 13 + installer.sh | 202 ++++++++++ requirements.txt | 4 + template.sql | 47 +++ 20 files changed, 1654 insertions(+) create mode 100644 VERSION create mode 100644 app/app.py create mode 100644 app/config.json create mode 100644 app/sslcheck.py create mode 100644 app/static/favicon.ico create mode 100644 app/static/style.css create mode 100644 app/static/ufo-box.png create mode 100644 app/templates/base.html create mode 100644 app/templates/bulk_hosts.html create mode 100644 app/templates/edit_host.html create mode 100644 app/templates/health.html create mode 100644 app/templates/hosts.html create mode 100644 app/templates/import_hosts.html create mode 100644 bin/backup_now.sh create mode 100644 db-admin-sslcheck.service create mode 100644 db-admin-sslcheck.timer create mode 100644 db-admin.service create mode 100644 installer.sh create mode 100644 requirements.txt create mode 100644 template.sql diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..e4604e3 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +3.2.1 diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..a31814a --- /dev/null +++ b/app/app.py @@ -0,0 +1,686 @@ +import os, json, datetime, csv, io, subprocess, time +from flask import Flask, request, redirect, url_for, render_template, flash, Response +import pymysql + +APP_ROOT = os.path.dirname(os.path.abspath(__file__)) +CFG = json.load(open(os.path.join(APP_ROOT, "config.json"))) + +# App start (for app-uptime metric) +APP_START_UTC = datetime.datetime.utcnow() + + +from functools import wraps +from flask import Response + +def _basic_auth_required(): + # If auth_user/auth_pass are not set, do not block access (but warn in logs). + u = CFG.get("site", {}).get("auth_user") + p = CFG.get("site", {}).get("auth_pass") + if not u or not p or p == "change-me": + return None + auth = request.authorization + if not auth or auth.username != u or auth.password != p: + return Response("Authentication required", 401, {"WWW-Authenticate": 'Basic realm="Host Registry"'}) + return None + +def require_auth(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + r = _basic_auth_required() + if r is not None: + return r + return fn(*args, **kwargs) + return wrapper + +def db_conn(): + return pymysql.connect( + host=CFG["db"]["host"], + port=int(CFG["db"]["port"]), + user=CFG["db"]["user"], + password=CFG["db"]["password"], + database=CFG["db"]["name"], + charset="utf8mb4", + autocommit=True, + cursorclass=pymysql.cursors.DictCursor + ) + + +def _fmt_duration(seconds: float) -> str: + try: + seconds = int(seconds) + except Exception: + return "unknown" + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + mins, _ = divmod(rem, 60) + if days > 0: + return f"{days}d {hours}h {mins}m" + if hours > 0: + return f"{hours}h {mins}m" + return f"{mins}m" + +def system_uptime_seconds() -> float: + # Linux: /proc/uptime => " " + try: + with open("/proc/uptime","r") as f: + return float(f.read().split()[0]) + except Exception: + return -1 + +def system_uptime_str() -> str: + sec = system_uptime_seconds() + if sec < 0: + return "unknown" + return _fmt_duration(sec) + +def app_uptime_str() -> str: + try: + sec = (datetime.datetime.utcnow() - APP_START_UTC).total_seconds() + return _fmt_duration(sec) + except Exception: + return "unknown" + +def load_avg_str() -> str: + try: + a,b,c = os.getloadavg() + return f"{a:.2f} / {b:.2f} / {c:.2f}" + except Exception: + return "unknown" + +def mem_str() -> str: + # MemTotal/MemAvailable from /proc/meminfo (in kB) + try: + total = avail = None + with open("/proc/meminfo","r") as f: + for line in f: + if line.startswith("MemTotal:"): + total = int(line.split()[1]) + elif line.startswith("MemAvailable:"): + avail = int(line.split()[1]) + if total is not None and avail is not None: + break + if total is None or avail is None: + return "unknown" + used = max(total - avail, 0) + # convert kB -> MB + return f"{used/1024:.1f} / {total/1024:.1f} MB" + except Exception: + return "unknown" + +def disk_str(path: str = "/") -> str: + try: + st = os.statvfs(path) + total = st.f_frsize * st.f_blocks + free = st.f_frsize * st.f_bavail + used = max(total - free, 0) + gb = 1024**3 + return f"{used/gb:.2f} / {total/gb:.2f} GB" + except Exception: + return "unknown" + +def db_health() -> tuple[bool, int, str]: + """Return (ok, host_count, error_message).""" + try: + with db_conn() as con: + with con.cursor() as cur: + cur.execute("SELECT COUNT(*) AS n FROM hosts") + n = int((cur.fetchone() or {}).get("n",0)) + return True, n, "" + except Exception as e: + return False, 0, str(e) + + + +def _format_uptime(seconds: float) -> str: + try: + seconds = int(seconds) + except Exception: + return "unknown" + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + mins, _ = divmod(rem, 60) + if days > 0: + return f"{days}d {hours}h {mins}m" + if hours > 0: + return f"{hours}h {mins}m" + return f"{mins}m" + +def _get_uptime_str() -> str: + # Prefer system uptime (best for VM/host); fall back to process uptime. + try: + with open("/proc/uptime","r") as f: + s = float(f.read().split()[0]) + return _format_uptime(s) + except Exception: + return "unknown" + +def _db_status_str() -> str: + try: + with db_conn() as con: + with con.cursor() as cur: + cur.execute("SELECT 1 AS ok") + cur.fetchone() + return "OK" + except Exception: + return "DOWN" + +def _backup_dir() -> str: + return (CFG.get("backup", {}) or {}).get("dir") or "/opt/outsidethedb/backups" + +def _backup_prefix() -> str: + return (CFG.get("backup", {}) or {}).get("prefix") or "outsidethedb" + +def run_db_backup() -> str: + """Create a gzipped mysqldump and return the output path.""" + bdir = _backup_dir() + os.makedirs(bdir, exist_ok=True) + ts = datetime.datetime.utcnow().strftime("%Y-%m-%d_%H%M%S") + out_path = os.path.join(bdir, f"db_{CFG['db']['name']}_{_backup_prefix()}_{ts}.sql.gz") + + env = os.environ.copy() + # Avoid password in process args; MySQL honors MYSQL_PWD. + env["MYSQL_PWD"] = str(CFG["db"]["password"]) + + cmd = [ + "mysqldump", + "--host", str(CFG["db"]["host"]), + "--port", str(CFG["db"]["port"]), + "--user", str(CFG["db"]["user"]), + "--single-transaction", + "--routines", + "--events", + "--triggers", + str(CFG["db"]["name"]), + ] + + # Pipe into gzip ourselves (portable across mysqldump versions) + p1 = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + p2 = subprocess.Popen(["gzip", "-c"], stdin=p1.stdout, stdout=open(out_path, "wb")) + p1.stdout.close() + _, err = p1.communicate() + rc = p1.returncode + p2.wait() + + if rc != 0: + try: + os.remove(out_path) + except Exception: + pass + raise RuntimeError(err.decode("utf-8", errors="replace").strip() or f"mysqldump failed rc={rc}") + + return out_path + +def parse_fqdn(fqdn: str): + fqdn = (fqdn or "").strip().lower().strip(".") + if not fqdn or "." not in fqdn: + return "", None, "" + parts = fqdn.split(".") + zone = ".".join(parts[-2:]) + sub = ".".join(parts[:-2]) or None + return zone, sub, fqdn + +def dt_from_html(val: str): + if not val: return None + val = val.strip() + # accept yyyy-mm-dd or yyyy-mm-ddTHH:MM + try: + if "T" in val: + return datetime.datetime.fromisoformat(val) + return datetime.datetime.fromisoformat(val + "T00:00:00") + except Exception: + return None + +def dot_class(row): + # blank if not configured + if not row.get("monitor_enabled"): + return "dot-blank" + now = datetime.datetime.utcnow() + # red if down + if row.get("status") == "down": + return "dot-red" + # orange if host expires within 30 days + he = row.get("host_expires_at") + if he and isinstance(he, datetime.datetime) and he <= now + datetime.timedelta(days=30): + return "dot-orange" + # yellow if SSL expires within 30 days + se = row.get("ssl_expires_at") + if se and isinstance(se, datetime.datetime) and se <= now + datetime.timedelta(days=30): + return "dot-yellow" + # green if up + if row.get("status") == "up": + return "dot-green" + return "dot-yellow" # enabled but unknown -> treat as warning + +app = Flask(__name__) +app.secret_key = CFG["site"]["secret_key"] + +@app.context_processor +def inject_meta(): + db_ok, host_count, _ = db_health() + return { + "version": CFG.get("version","v3.2.1"), + "year": datetime.datetime.now().year, + "sys_uptime": system_uptime_str(), + "app_uptime": app_uptime_str(), + "db_ok": db_ok, + "host_count": host_count, + } +@app.route("/") +@require_auth +def index(): + return redirect(url_for("hosts")) + +@app.route("/hosts") +@require_auth +def hosts(): + q = request.args.get("q","").strip() + rows=[] + sql = """ + SELECT * + FROM hosts + """ + args=[] + if q: + like=f"%{q}%" + sql += " WHERE (fqdn LIKE %s OR zone LIKE %s OR sub LIKE %s OR client_name LIKE %s OR email LIKE %s OR public_ip LIKE %s OR private_ip LIKE %s OR pve_host LIKE %s OR notes LIKE %s)" + args=[like,like,like,like,like,like,like,like,like] + sql += " ORDER BY zone ASC, (sub IS NULL OR sub='') DESC, sub ASC, fqdn ASC LIMIT 2000" + try: + with db_conn() as con: + with con.cursor() as cur: + cur.execute(sql, args) + rows = cur.fetchall() + except Exception as e: + flash(f"DB error: {e}", "err") + rows=[] + # compute dot class & pretty fields + for r in rows: + r["dot"] = dot_class(r) + r["host_display"] = r["fqdn"] + r["ssl_expires_str"] = r["ssl_expires_at"].strftime("%Y-%m-%d") if r.get("ssl_expires_at") else "" + r["host_expires_str"] = r["host_expires_at"].strftime("%Y-%m-%d") if r.get("host_expires_at") else "" + r["last_check_str"] = r["last_check_at"].strftime("%Y-%m-%d %H:%M") if r.get("last_check_at") else "" + return render_template("hosts.html", rows=rows, q=q) + +@app.route("/hosts/new", methods=["GET","POST"]) +@require_auth +def host_new(): + if request.method == "POST": + fqdn = request.form.get("fqdn","").strip().lower().strip(".") + zone = request.form.get("zone","").strip().lower().strip(".") + sub = request.form.get("sub","").strip().lower().strip(".") or None + if not zone: + zone, sub2, fqdn2 = parse_fqdn(fqdn) + if not fqdn: + fqdn = fqdn2 + if sub is None: + sub = sub2 + if fqdn and not zone: + zone, sub2, _ = parse_fqdn(fqdn) + if sub is None: + sub = sub2 + if not fqdn: + # build from zone/sub + if not zone: + flash("Zone or FQDN is required", "err") + return render_template("edit_host.html", host={}, is_new=True) + fqdn = f"{sub+'.' if sub else ''}{zone}" + # other fields + data = { + "zone": zone, + "sub": sub, + "fqdn": fqdn, + "monitor_enabled": 1 if request.form.get("monitor_enabled")=="on" else 0, + "public_ip": request.form.get("public_ip") or None, + "private_ip": request.form.get("private_ip") or None, + "pve_host": request.form.get("pve_host") or None, + "client_name": request.form.get("client_name") or None, + "email": request.form.get("email") or None, + "country": request.form.get("country") or None, + "package_type": request.form.get("package_type") or None, + "dns_provider": request.form.get("dns_provider") or None, + "notes": request.form.get("notes") or None, + "host_expires_at": dt_from_html(request.form.get("host_expires_at","")), + } + try: + with db_conn() as con: + with con.cursor() as cur: + cur.execute(""" + INSERT INTO hosts + (zone, sub, fqdn, monitor_enabled, public_ip, private_ip, pve_host, client_name, email, country, package_type, dns_provider, notes, host_expires_at) + VALUES + (%(zone)s, %(sub)s, %(fqdn)s, %(monitor_enabled)s, %(public_ip)s, %(private_ip)s, %(pve_host)s, %(client_name)s, %(email)s, %(country)s, %(package_type)s, %(dns_provider)s, %(notes)s, %(host_expires_at)s) + """, data) + flash("Host added", "ok") + return redirect(url_for("hosts")) + except Exception as e: + flash(f"DB error: {e}", "err") + return render_template("edit_host.html", host=data, is_new=True) + return render_template("edit_host.html", host={}, is_new=True) + +@app.route("/hosts//edit", methods=["GET","POST"]) +@require_auth +def host_edit(hid): + host={} + try: + with db_conn() as con: + with con.cursor() as cur: + cur.execute("SELECT * FROM hosts WHERE id=%s", (hid,)) + host = cur.fetchone() or {} + except Exception as e: + flash(f"DB error: {e}", "err") + host={} + if request.method=="POST": + host.update({ + "zone": request.form.get("zone","").strip().lower().strip("."), + "sub": (request.form.get("sub","").strip().lower().strip(".") or None), + "fqdn": request.form.get("fqdn","").strip().lower().strip("."), + "monitor_enabled": 1 if request.form.get("monitor_enabled")=="on" else 0, + "public_ip": request.form.get("public_ip") or None, + "private_ip": request.form.get("private_ip") or None, + "pve_host": request.form.get("pve_host") or None, + "client_name": request.form.get("client_name") or None, + "email": request.form.get("email") or None, + "country": request.form.get("country") or None, + "package_type": request.form.get("package_type") or None, + "dns_provider": request.form.get("dns_provider") or None, + "notes": request.form.get("notes") or None, + "host_expires_at": dt_from_html(request.form.get("host_expires_at","")), + }) + # rebuild fqdn if missing + if not host.get("fqdn") and host.get("zone"): + host["fqdn"] = f"{host.get('sub')+'.' if host.get('sub') else ''}{host.get('zone')}" + if not host.get("zone") and host.get("fqdn"): + z,s,_ = parse_fqdn(host["fqdn"]) + host["zone"]=z + host["sub"]=s + + # final validation + if not host.get("fqdn") or not host.get("zone"): + flash("FQDN and zone are required (zone can be derived from FQDN).", "err") + return render_template("edit_host.html", host=host, is_new=False) + try: + with db_conn() as con: + with con.cursor() as cur: + cur.execute(""" + UPDATE hosts SET + zone=%s, sub=%s, fqdn=%s, + monitor_enabled=%s, + public_ip=%s, private_ip=%s, pve_host=%s, + client_name=%s, email=%s, country=%s, + package_type=%s, dns_provider=%s, + notes=%s, host_expires_at=%s + WHERE id=%s + """, (host["zone"], host["sub"], host["fqdn"], + host["monitor_enabled"], + host["public_ip"], host["private_ip"], host["pve_host"], + host["client_name"], host["email"], host["country"], + host["package_type"], host["dns_provider"], + host["notes"], host["host_expires_at"], + hid)) + flash("Saved", "ok") + return redirect(url_for("hosts")) + except Exception as e: + flash(f"DB error: {e}", "err") + # html datetime-local value + if host.get("host_expires_at"): + try: + host["host_expires_at_html"] = host["host_expires_at"].strftime("%Y-%m-%dT%H:%M") + except Exception: + host["host_expires_at_html"] = "" + else: + host["host_expires_at_html"] = "" + return render_template("edit_host.html", host=host, is_new=False) + +@app.route("/hosts//delete", methods=["POST"]) +@require_auth +def host_delete(hid): + try: + with db_conn() as con: + with con.cursor() as cur: + cur.execute("DELETE FROM hosts WHERE id=%s", (hid,)) + flash("Deleted", "ok") + except Exception as e: + flash(f"DB error: {e}", "err") + return redirect(url_for("hosts")) + +@app.route("/hosts/export.csv") +@require_auth +def hosts_export(): + # export all rows + try: + with db_conn() as con: + with con.cursor() as cur: + cur.execute("SELECT zone, sub, fqdn, monitor_enabled, public_ip, private_ip, pve_host, client_name, email, country, package_type, dns_provider, host_expires_at, ssl_expires_at, notes FROM hosts ORDER BY zone ASC, (sub IS NULL OR sub='') DESC, sub ASC, fqdn ASC") + rows = cur.fetchall() + except Exception as e: + return Response(f"DB error: {e}\n", status=500, mimetype="text/plain") + output = io.StringIO() + w = csv.writer(output) + w.writerow(["zone","sub","fqdn","monitor_enabled","public_ip","private_ip","pve_host","client_name","email","country","package_type","dns_provider","host_expires_at","ssl_expires_at","notes"]) + for r in rows: + w.writerow([ + r.get("zone",""), + r.get("sub") or "", + r.get("fqdn",""), + int(r.get("monitor_enabled") or 0), + r.get("public_ip") or "", + r.get("private_ip") or "", + r.get("pve_host") or "", + r.get("client_name") or "", + r.get("email") or "", + r.get("country") or "", + r.get("package_type") or "", + r.get("dns_provider") or "", + r.get("host_expires_at").strftime("%Y-%m-%d") if r.get("host_expires_at") else "", + r.get("ssl_expires_at").strftime("%Y-%m-%d") if r.get("ssl_expires_at") else "", + (r.get("notes") or "").replace("\r"," ").replace("\n"," ").strip(), + ]) + data = output.getvalue().encode("utf-8") + return Response( + data, + mimetype="text/csv", + headers={"Content-Disposition":"attachment; filename=hosts-export.csv"} + ) + +@app.route("/hosts/import", methods=["GET","POST"]) +@require_auth +def hosts_import(): + if request.method == "POST": + f = request.files.get("csvfile") + if not f: + flash("No file uploaded", "err") + return redirect(url_for("hosts_import")) + content = f.read().decode("utf-8", errors="replace") + reader = csv.DictReader(io.StringIO(content)) + count=0 + errors=0 + with db_conn() as con: + with con.cursor() as cur: + for row in reader: + try: + fqdn = (row.get("fqdn") or "").strip().lower().strip(".") + zone = (row.get("zone") or "").strip().lower().strip(".") + sub = (row.get("sub") or "").strip().lower().strip(".") or None + if not fqdn: + if not zone: + continue + fqdn = f"{sub+'.' if sub else ''}{zone}" + if not zone: + zone, sub2, _ = parse_fqdn(fqdn) + if not sub: + sub = sub2 + monitor_enabled = int(row.get("monitor_enabled") or 0) + host_expires_at = dt_from_html((row.get("host_expires_at") or "").strip()) + # upsert by fqdn + cur.execute(""" + INSERT INTO hosts + (zone, sub, fqdn, monitor_enabled, public_ip, private_ip, pve_host, client_name, email, country, package_type, dns_provider, notes, host_expires_at) + VALUES + (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE + zone=VALUES(zone), + sub=VALUES(sub), + monitor_enabled=VALUES(monitor_enabled), + public_ip=VALUES(public_ip), + private_ip=VALUES(private_ip), + pve_host=VALUES(pve_host), + client_name=VALUES(client_name), + email=VALUES(email), + country=VALUES(country), + package_type=VALUES(package_type), + dns_provider=VALUES(dns_provider), + notes=VALUES(notes), + host_expires_at=VALUES(host_expires_at) + """, ( + zone, sub, fqdn, monitor_enabled, + (row.get("public_ip") or "").strip() or None, + (row.get("private_ip") or "").strip() or None, + (row.get("pve_host") or "").strip() or None, + (row.get("client_name") or "").strip() or None, + (row.get("email") or "").strip() or None, + (row.get("country") or "").strip() or None, + (row.get("package_type") or "").strip() or None, + (row.get("dns_provider") or "").strip() or None, + (row.get("notes") or "").strip() or None, + host_expires_at + )) + count += 1 + except Exception: + errors += 1 + continue + flash(f"Imported {count} rows ({errors} skipped)", "ok" if errors==0 else "warn") + return redirect(url_for("hosts")) + return render_template("import_hosts.html") + + +@app.route("/hosts/bulk", methods=["GET","POST"]) +@require_auth +def hosts_bulk(): + zone = "" + subs = "" + include_apex = True + defaults = { + "client_name": "", + "email": "", + "country": "", + "package_type": "", + "dns_provider": "", + "public_ip": "", + "private_ip": "", + "pve_host": "", + "monitor_enabled": False, + } + if request.method == "POST": + zone = request.form.get("zone","").strip().lower().strip(".") + subs = request.form.get("subs","") + include_apex = (request.form.get("include_apex") == "on") + defaults.update({ + "client_name": (request.form.get("client_name") or "").strip(), + "email": (request.form.get("email") or "").strip(), + "country": (request.form.get("country") or "").strip(), + "package_type": (request.form.get("package_type") or "").strip(), + "dns_provider": (request.form.get("dns_provider") or "").strip(), + "public_ip": (request.form.get("public_ip") or "").strip(), + "private_ip": (request.form.get("private_ip") or "").strip(), + "pve_host": (request.form.get("pve_host") or "").strip(), + "monitor_enabled": True if request.form.get("monitor_enabled") == "on" else False, + }) + if not zone: + flash("Base zone is required.", "err") + return render_template("bulk_hosts.html", zone=zone, subs=subs, include_apex=include_apex, defaults=defaults) + + # parse subdomains + raw = subs.replace(",", "\n") + parts = [] + for line in raw.splitlines(): + s = line.strip().lower().strip(".") + if not s: + continue + # allow full hostnames pasted + if "." in s and s.endswith(zone): + # take sub part only + s = s[:-(len(zone)+1)] + parts.append(s) + # de-dupe while preserving order + seen=set() + sub_list=[] + for s in parts: + if s in seen: + continue + seen.add(s) + sub_list.append(s) + + to_create=[] + if include_apex: + to_create.append((zone, None, zone)) + for s in sub_list: + fqdn = f"{s}.{zone}" if s else zone + to_create.append((zone, s, fqdn)) + + created=0 + updated=0 + try: + with db_conn() as con: + with con.cursor() as cur: + for z, sub, fqdn in to_create: + cur.execute("SELECT id FROM hosts WHERE fqdn=%s", (fqdn,)) + row = cur.fetchone() + if row: + cur.execute("""UPDATE hosts SET zone=%s, sub=%s, + monitor_enabled=%s, + public_ip=%s, private_ip=%s, pve_host=%s, + client_name=%s, email=%s, country=%s, + package_type=%s, dns_provider=%s + WHERE fqdn=%s""", ( + z, sub, + 1 if defaults["monitor_enabled"] else 0, + defaults["public_ip"] or None, defaults["private_ip"] or None, defaults["pve_host"] or None, + defaults["client_name"] or None, defaults["email"] or None, defaults["country"] or None, + defaults["package_type"] or None, defaults["dns_provider"] or None, + fqdn + )) + updated += 1 + else: + cur.execute("""INSERT INTO hosts + (zone, sub, fqdn, monitor_enabled, public_ip, private_ip, pve_host, + client_name, email, country, package_type, dns_provider, status) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'unknown')""", ( + z, sub, fqdn, + 1 if defaults["monitor_enabled"] else 0, + defaults["public_ip"] or None, defaults["private_ip"] or None, defaults["pve_host"] or None, + defaults["client_name"] or None, defaults["email"] or None, defaults["country"] or None, + defaults["package_type"] or None, defaults["dns_provider"] or None + )) + created += 1 + flash(f"Bulk add complete: {created} created, {updated} updated.", "ok") + return redirect(url_for("hosts")) + except Exception as e: + flash(f"DB error: {e}", "err") + + return render_template("bulk_hosts.html", zone=zone, subs=subs, include_apex=include_apex, defaults=defaults) + + +@app.route("/backup-db", methods=["POST"]) +@require_auth +def backup_db(): + try: + out_path = run_db_backup() + flash(f"DB backup created: {out_path}", "ok") + except Exception as e: + flash(f"Backup failed: {e}", "err") + return redirect(url_for("hosts")) + +@app.route("/health") +def health(): + ok=False + host_count=0 + try: + with db_conn() as con: + with con.cursor() as cur: + cur.execute("SELECT COUNT(*) AS n FROM hosts") + host_count = int((cur.fetchone() or {}).get("n",0)) + ok=True + except Exception as e: + flash(f"DB error: {e}", "err") + return render_template("health.html", ok=ok, host_count=host_count) \ No newline at end of file diff --git a/app/config.json b/app/config.json new file mode 100644 index 0000000..d5b0b85 --- /dev/null +++ b/app/config.json @@ -0,0 +1,21 @@ +{ + "db": { + "host": "127.0.0.1", + "port": 3306, + "name": "db_admin", + "user": "db-user", + "password": "1securep455wordhere" + }, + "site": { + "fqdn": "db.example.com", + "secret_key": "change-me", + "theme": "dark", + "auth_user": "db-user", + "auth_pass": "change-me" + }, + "version": "v3.2.1", + "backup": { + "dir": "/opt/outsidethedb/backups", + "prefix": "outsidethedb" + } +} diff --git a/app/sslcheck.py b/app/sslcheck.py new file mode 100644 index 0000000..d5a90aa --- /dev/null +++ b/app/sslcheck.py @@ -0,0 +1,114 @@ +\ +#!/usr/bin/env python3 +import os, json, socket, ssl, datetime +import pymysql +import requests + +APP_ROOT = os.path.dirname(os.path.abspath(__file__)) +CFG = json.load(open(os.path.join(APP_ROOT, "config.json"))) + +def db_conn(): + return pymysql.connect( + host=CFG["db"]["host"], + port=int(CFG["db"]["port"]), + user=CFG["db"]["user"], + password=CFG["db"]["password"], + database=CFG["db"]["name"], + charset="utf8mb4", + autocommit=True, + cursorclass=pymysql.cursors.DictCursor + ) + +def get_cert_not_after(hostname: str, port: int = 443, timeout: int = 8): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with socket.create_connection((hostname, port), timeout=timeout) as sock: + with ctx.wrap_socket(sock, server_hostname=hostname) as ssock: + cert = ssock.getpeercert() + # notAfter format like: 'Jun 5 12:00:00 2026 GMT' + na = cert.get("notAfter") + if not na: + return None + return datetime.datetime.strptime(na, "%b %d %H:%M:%S %Y %Z") + +def http_status(url: str, timeout: int = 8): + # Don't download big content; HEAD sometimes blocked, so use GET with small read + r = requests.get(url, timeout=timeout, allow_redirects=True, verify=False, stream=True, headers={"User-Agent":"outsidethebox-host-registry/1.0"}) + try: + r.close() + except Exception: + pass + return int(r.status_code) + +def compute_status(code_http, code_https, err_http, err_https): + # Green if 2xx on 80 OR 443 + def is_green(code): + return code is not None and 200 <= code <= 299 + if is_green(code_http) or is_green(code_https): + return "up" + # Red if got a response but not 2xx, OR any connection-type error + if code_http is not None or code_https is not None or err_http or err_https: + return "down" + return "unknown" + +def main(): + now = datetime.datetime.utcnow() + rows=[] + with db_conn() as con: + with con.cursor() as cur: + cur.execute("SELECT id, fqdn, monitor_enabled FROM hosts WHERE monitor_enabled=1 ORDER BY zone ASC, fqdn ASC") + rows = cur.fetchall() + + for r in rows: + hid = r["id"] + fqdn = (r["fqdn"] or "").strip().lower().strip(".") + code_http = None + code_https = None + ssl_exp = None + err_http = "" + err_https = "" + last_error = "" + + # HTTP 80 + try: + code_http = http_status(f"http://{fqdn}/") + except Exception as e: + err_http = f"http: {type(e).__name__}: {e}" + + # HTTPS 443 + cert + try: + code_https = http_status(f"https://{fqdn}/") + except Exception as e: + err_https = f"https: {type(e).__name__}: {e}" + + try: + ssl_exp = get_cert_not_after(fqdn, 443) + except Exception as e: + # only store as error if https is enabled; still useful for debugging + last_error = (last_error + " ; " if last_error else "") + f"cert: {type(e).__name__}: {e}" + + if err_http: + last_error = (last_error + " ; " if last_error else "") + err_http + if err_https: + last_error = (last_error + " ; " if last_error else "") + err_https + + status = compute_status(code_http, code_https, err_http, err_https) + + with db_conn() as con: + with con.cursor() as cur: + cur.execute(""" + UPDATE hosts SET + status_code_http=%s, + status_code_https=%s, + status=%s, + last_check_at=%s, + last_error=%s, + ssl_expires_at=%s + WHERE id=%s + """, (code_http, code_https, status, now, last_error[:1000] if last_error else None, ssl_exp, hid)) + + print(f"Checked {len(rows)} host(s) at {now.isoformat()}Z") + +if __name__ == "__main__": + main() diff --git a/app/static/favicon.ico b/app/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4f0f6bf7cfbda9e5fff2d802dc372943d1630beb GIT binary patch literal 13854 zcmb7r^K&I#_itvxC$??dww;-YtrOeUiEUdG8xz}3PHfx8H}CxqZdY~h>Zgx5; z-r>rMQV6iPuwY7nkFmJ&C3G|~(dp)u z|1>q{)dEKw!O*)|fNCtqF+_ft3flLXk7i+^tQ*ik#WPYD|AA2y_7l_d>R;{npXPNh z^TVtyBDG81fwIz>6IHJc zI5ABXa~w(#g17xQ;;OUh{^oNyp)F(j+ol4%eKD}INs=Z+_3wW*r4G6wd#&6tkr&)M zTiYC&uFsZY;f&Y{Nr02kkEN>UQ;H98>lf*q+rOI-R^9#P%8*edn)qv z**P*kDh}fic4l#Uy4TUR?W337lC^U82en0nhf%esRYl-=RYDD$r6WwRv)y1M<$$6= z;_8zosnLee`(DD=>q*VT)a-U{LSJQ}<&A{ueJj2Kor9z4{q-r9*=vdwkz666x~~aC zmaY*~nnW0YY&wF$=YF_&U?QR5NXCh!%badEk*27phzXpWkcyoUu> z%-}-c?yNeWF2uwTth$5j3@ww1gA4}8IF@6I7;}pLmOR4wXwysvw0jbIK6`xLJnM|n z*;qr!Qp}15Ev1Q)Hc95IbkREzU4%E0DmdF(# zYlDgFE*;-l9Pz^V=?4MO?{lV_-B-KMz~NlK*bJ!JDLjx zo+WPr7kTV#*~H7^qmipFZ&kI@|8(=hc^YOg4Aw(|3^oC}vb$dBXuiq76=XZBo zk`YK*_y_KGN7+X#>#W;}%4t(8vO>fnc z#Y5a*Buw_lzDG3M+_4n>0Q~Xo$icJgO4Py-+u2Y*PLf5&WzS2Ngo{Qjs$U{z|8MQ! zGt^v7MROhiA0V=XTP`8Iq@|`$WPTWF=lCVoK)s>=Qe0iycsM((U&w3dUMmxwkNZ4} zRFm(s_7h&L;M#j_&Gw7!w@i3N5>wg_1Xuz_P7(-JX(gt&niDW@_Dr`_Cy+pF{=$)d zR&@mcARQ;Ys(`uu(y>5FOjqn-v~;%qIuHG_a%Jwgk}O1S!58XNwc$0E_OdRjqfUm5 z{ZlcEJ*+_~H`oxHT0LH#{AGNL(D(t!nuH4B2R7Z4fJ#zCRlz}x{$ogMyN-L)$UbSm zI2B8+z5CWlhNOx^yX9)99q%*r>q+QIc`J^)(p*5tl0~Skpa7SFUMIh->@T-<`~^4S zbSHBxuX;ga^isw|6pa4AWBBGP>XQ7fn2!Sv^ee`r9jerAVzB7Y%=>pU_C5?iQfL_j zGRy^zf!51V>VouzPqoP4a;+is#Tw?drm7ar9 z%!n#%b?ZL;pQ_D~!>WHTg9Kb?bzRTOI>lkbi5>r(31GmhbmL}}K|KsD?>|^c4NMa3 zIYz}3iY`3YVM$uMm=$S^Pd*;JO|Po9??eMqHsK!r%jCdwWwt#hCPg{h0y2j9qfsFo z<;Hf%>lqT7=lH&3`7SYuF&v6oRE1L|!1OS~S5AXzT18i&j|NYs5O}=+7Acpiwgxj6 zGd0^h`Jhob=a`ZGe{rfV=a86qtz(Z*lX3|qcAR6+N<)mtNkhO!|H6f~oBg%Op{$KZ zg`EC?<-L`b|Po*$m981F&qtD92MRKPo z#$iOV_|7qVKAy|O2Kb!J=KCvNz=laM6kTH`BD=q0>3;x~YGzme<<8|Li{_UraaXRX zPQ1o0si~+;@I``@+Z&=<%aIikUFk(xEsUuH@^{f6&c>3|q?|r^sAj8e1T{5T=;y6f z#{d2PMkb*h**f=?VgA?SNP7LI%(*e%#%%KGKp85;y(BcrxqnJnyw2y+r7;&HVOkJx z2rw7WY{C)3rx8h)Smi{rCzAehB;HT73Vx*p6yyY%v^S4a(7WaH@mBG$T}(0;zo&~Ej#H?C=Exw(JWwBmVG>9cRsGNiiPXl!M#?WX;tkzs;_j0^~- zQ$d$B&lad@jL_|kzshIQm zYJ5Y{r&76kHf1Zh#D=ZbYky=P0&pMCD~gQlIAmJ^t+5Y1-MYV}vX{ zA|ZWTvUdiNY|M4!zP0rkSr6YvCRO@lA#uR@79-FhJ{5t5q)27Bh@O zL4R*qM@Fa&y}InSuVD_Z=w-s^a*`|Z;#(g%(a3xGwA?>!kn>Rk5-?X|)!>q3L%0NT zo-PX;Z+(#l8fT;_I&w#@?|5OtQ7Q?dlTa-*u|*9Q2!t)bsW3&PWq>hv$3ZPl??pQr zW_OWA5v@ z_=A>WS^tY3qTmvntI;NbaRSY0L#&uuNzDkxc!;vmYm``3N}aEQ)E&S&=2V z{`nu$$;gHSR{K4K;$)a9rpVzLI`%hhFbD24GCMdXF_R#3?~px7gghS+M39aLV_O~h zTq=Ify>b-&=>#2{-*7|hd&)|KuUzv<;0}Y}L9D`aS9S}rz*;YJo_{zJGfET9D8D6PjN05rt#1 z$$Gs#Q$lVjASs6tsirjj;}F(NKqwWXqUH89YtZ}7s~eh*t%)XIMBKQe^H_}5%5p_t zZ?N#oGX(r5t$XL(hQGo+u)?k*n2`W>bSwfjmTUXeUZ~&cj+h;{sC1Mic~X-KX|L5m zEK^{mX3&Mzoq{q}kR$Wo=QTM;j6|QqY>XsMPCOYh^@Y(-a4)Ru!nGPQA*!IOjYllb z3wkMHdlhMhxStayS=5Nlu61M{+!nEAHy?&ob;xN81rnGt76r*vGXfvSKEtTE4b>6S z>_MLN_%tfKO)w}kg3z{~OQzT97h1)RJ=}sAbGs6Y0g>|ja&7lb&5bmnN-T=n==)k;_+uW%%yo8UGB+qNte0D(U94Ja&p(>Z z=>`3*w2o(RF{RTZLZ#%v<14AC*7h4|_IR$=6LC}*g4Wlq3sm6dnKJ~g9jL!$4v~Dd zDtEJ(i-N#ajn3p>jy27H4pexY7X|M66fr;7Zd5&skkeGzK00J{ygy~`FBU@p3#5ks z&KVht>pLRmZfXBooqn?%y(~%BjFyz5UT1c}dFi_i z`Iz#n@P`6nWkfugk0BZ4Lj!gFh}!BF-|$*#2)gw7Zf@e>Rh8ij^x5)n9a(-F&Gj)-! zA)`lI`sZWOfXwQzU!eMm)~vAzOKUQ45^_zJ-kAbhc%1dj@3B#$Uc=~ov6@pErGYjr z%9dbj=%`z~rQN2uWj}t^Xv3#}swGQv(O&I}ufEn?nAluJM{9MzlKLsMQF0~WxqkyX z*`0IDmR? zNU~bo%@{M?oSt2&`Q%-~6}J(}_iol3O3Tmpuqxo9$}Cl`1mdu?J_o7+)YGH}WW$$$ zKyi(ElMJZYFbS31YI$6>U=qkHM*YY=?zEEBsXI+z;v>b_w4aO66F+le`zx^DH>ack z6I_!GXaAx|=(PjNTmvCS`fNa?_6+oHe6mkNlN)Wlka1w0io`cm$UVLNrc`!xpR2N zL_){b|5d%6F+M%8M>N=^u4o0+d=Fee1^TPX^2JmnHW%vq`jlUCo#Jko^_ac}R`^xR zjng*mwXV8sNNzbK&y5SMmaSpwR<2>_R(stGDcNl`U8!B|a94kBZSfbZA+^dsVLOKF z{d0ID8IG4<4P`G$Qhf!v2~_eScE~>wdPM6zPes_v%d9Ou2|ica@@=bbbX_@r+@8rb zCUS0Wzu*tsU;wou!_InU>g0pw6>$e2*rs|CWIpfDGHK{X2ATJ%bZCC;ADPADu=Jp7 zpd=obJ}+u{je5ljeLz*uMPbin=IHVfI^w6e96M{0K&0xkMwdC4%PmiMSb1o1mXg;H zie(xKHNTN1;>=^6Q-$Xzd1UL#gbW|kPl;0nzS zm$ZX_AJ$eH{^44Ojxz}aMNJ%&2(?%Y3(}qu$UdIKZ4jrY%bfg}jEG&zPcl0X`*glc z3Ub642b~wa!nu-KA}m-6f0FLEN4&mrzm3tl-@deYcbGZBsInS5O?N64$R}o(zBF0Ms&T5_PATygUZvH)5_`xSH zEDmdPgtoLQe$uOI?JXmQ7kx`*ipp1aGAq#KexZx@a&38<$(-4KoF4zvK2tpElFfSW zaD^*bu|)2a$NrJ|UVDViTI7nl%4o1kkl05SK$?^}?o*2kAt+H1e{w6=I}+)SZI6Qv zs}bf=mSBL;#{~uAKqCL`$aoZiO7L8Uwx1)C6PCAp>J$MEXBn1+4=;;>0Ta~ynWUjF zVv6arge!wpauaP_Q!ag;js(A45Dw;_~^#$s9y3ud+7zFH;e=r-n)Cc&e4H8m4ISuIBxd_z)J-xB7#9UY5eao7bD+|&dTjPF-L(xv%s zj>-$TpyP2(6fFG-18^cTYGsfY?>|svrni`o)s;!4K0-QQW%ZZEfAHKq&qfid?7z&w zbOWyLo@n{-Em`84fE$Cw*2}foPeRKBeS<`|pJUN5bZ8V5t$L!_6_BDd-*&pBUefQhe25!0QUlaqM?D0|)IM9OGbWHs63#7f=XEZza;Qg?kn9gF% z%Vs8TK06odew6K|*Q(!fHPTaE(!WoWt!c-e7L=}`5%2F zNlUPN@%OB>^Oo`S<3;9AkMw#C*&sdbk()iBfhMN06y|KFF$v!o()HomI89Y${x^X^ z8Q;BE8)AJ4o?UL93|RG~t+4X2`E)#-)E>z=z?+%HNA2sIW$dfv-iF1bU+YOgYDQ&I zSRFw3>pH`vj3HX3KWUTtpUzJIT4MEmZ|GLb?x~=IWhKzX=BY>O_iQn~j?;rD5{&ch z@r=`*=!lJBqGXHPQHD!++zut+SQUO2ef;J(hpz<$fKKh5J%dN8@?(lDJ z*+ShA%%8&!9Yy>G%VAyopSS-ed_DwxZwqS@O|gj~j3ploL1%R3f8AbstK4pv$+`#P zeu`yiP@0&CkBp8M7>9oQ8uQ6`HG-M#uMia=8>5d?1@8_EETXE_!J!LN0F5*`KB_~e zd*A*U*T$$Z(#F&=3U~(oG1=+2c~@RtexU9qD5|htgR(?;{JEoco^2Y>*%tkimNeb0NsjWtgJiQrPC zr>h!KY6S%s43P~WJ9&1yi|FuK8MnRv?wqVVO}IFHNe(Nj8f-%IZcr?zT769*@mgY@ zwWPa*V~QzixYqu@8M8OQuO!EVv>)n$R7lPU;)4EtlgyQL^piI<_j4;~fomYMV;Y2V zV6x`$GHlFLXXeBss{z9<(YczKYFg)gpd~ITXxr!y+FF@7v-=X_Z5}Uld*X~D(jRD> zFJUe#OND0lT_n-lVo-16|Cn1NPvk7AZQJnzAxEdd^vIBFzRf~a(#s4p>h}4PRG+D; z{|>@LW$+f#a)&dRnCTLy4~fF4wVCIHdOV4M03tGx@)^y~fc=-;3*^8%*=)0MSGah> zb!bXWdtT~$^4NLtuwHFXjpm~(tBN(&Vkxo^7DMRtbnTm=)PDSBKsWhDWOKCnmb0B= zrz=4|=lt#_E~3}z3g`EJ#zvhUB8l?*KijX~!7PU{_vXvhVY%sPvz@u7%B}j`xd`s! z-<)r>Pq&+=KJOcJvy(<%v{A($cGpYt83I{XiRjA@l$2y1Jq~)rpNX0X^YV;S%&v zkCy#20UNFR^-kDq^y{|crIBXnXipPh)PGi6h?#GMA%KI(Xmg#OVNm|zwLdL+i zsTrJ(m0l#s%7_kH-NeWQJFQx*`M`dWYufPDLk$4pOg*oxWHzU#;!8a1_GJ`$&y|v~ z42l4fZ?&GJPI$abqwkw?jjj`+#3idvc?v^8V=^)axr^4 z(-ze-=XPQ|EUd98@*bEa85)qyR`>OgU@pSrtYp@az1qJI7)If<#I0x1cP}Q|sXfz4 zWdy=a^F7LIckX4;cQl$#l*z2ciEl_)nCzwUzV~OZ0ik`k!>w_h_-!T+4Wr zrDQB1k%V4JnfN(Ufzp^U%*bQlqe)QdC^DEX@&a$C|gij0s_h z>S;2$)i&5Ht$gO$eCd0`NQ2jrlfiK`QuKg~zn#(0jU^@30bGZS22*Epnhp6;7lUsO z1mK3A91E&6>#L5E(RdTZ`aH?`VsESl!bJ6Kg~0?@BvpY4abBI;mR}AXp>FttcTAIe zz^l*yFh++afVx;J7)+Y3++5A`&$6O!&n%tUo2_hX}ycrB*?J4QdsY}trSK+!pv z6V$&Qo7kFfaZ^V!nDf*qaY!d##2O$Y5G;dTMWaJXD%!g~>^tBWazeCfxiCI^{NRoe zmSQM+Jk4~u1Pe$r=24rtYA}>mEq0Lkj8>?T{+ohrUmko8Tsg-# zV7J(Q2rzYgvNX?$BLr#NW(_fHb==D(w{hZ)XjzRts@rdx3YqQ2;0ha@Lb!e2dNRJ9 zqHi53gbxd|!_8vf`_Il*IR5-LvmvcF+7taYxgsMx6{-@JWrojo$RSFQM@%TFqP{-_NT=_(cVlF7 zloT`4AmdL)%)=07#C}gH=|wtjXQdh(X&4&yU~~3hmd8}f3K7?=FeYbBr#n_qgeM{A z%p_F(UOWHCx=xqRTM#ildWLHo%jH|Y8@7-oSUg|=uW{=)Qze%o0>YfQ#mJ~u=v%it z(_3yo6Z%v8^-6R9H-AZ~(djRsAZoB4Yrw5lxqXrF%lWusn zKb#K?uzcX~G%_VpMwfV%a*7+NFv%#DU=|>kOJIc=SK-C3d1Z+Skoo=)030MpuUc<5 zXXd`&*U-P%V`)jl({P^+_`Y9E3;j3m+fB`p=~C5HQqYd628-W;D45J4Q{DP*9+f*2 zl-Vs1B=XUokeeBeQ^)*a={MK5bk&(J@i$sc<13p}lQf#F=sIG$xG_xfF3oe5T#?V^ z=Z$+{)0(XNdA@nt!>;45=x!^lI7EIY<4=#)EZ-sIE{6?@`>VxpfF$}jJJJ}wf>myo zr^Z&1wB1co!F5kU5Fz6Uz?x4+L4kqKE%o-_zt7$|H}xIKHGgJr46rx3@j9f26F&9^ zML}il>4~GmyI8-jv7xGB14#!0rgtitnF?(ZW8lMXLgci`-13fezYu0m?t)lAc@Px7 zR`8HqE7;FS{^*9ey){{jrRuFdb;gdMThBEsGLk^BLbJZOYW|PquY`0Qn*|wAw@29@ z)xW!W7$+5Y#ZBD?5~n=xH?6W>nPf5JR(4(EcS_x3rwYaA%W5H<@y`&cCkxT)g+6Aq&bc0e0D5s=AEOVXR_2Aeqc75$nqKm8NLE*_6G+?4t>G- z_e`e}1MIk(<#DWkIwHL;68lCMX!_|D{7cqZs>+qf(;YqtCbT;cKo6L(P=NnL|AWuo zFK8Hc1le^fv0H{b_LXLXu*<@neek-Dx>2#iQK>i8r%o5~((WE*^*kOKXm?>zM(_Ew zE$s0fok-!&H)5QocylVMxid8eUguucq$&kh9Qd;vzJDO5NzH*yKi?GGHR8)3Ye%A;(6nH*V zi%OD-hB)u9F?4O`f%E;J%K?NImAu}($EoO4aMr^lIgKl=E=Wl47c8549TBaW3LU+@ z80CKwP=4UB_F|~#jB=#rDy(sOn3xS^RDKMgFo}Y?D0G^;BXZxT_1!%_X|09CMBl0i zj`H0;CUE`kHX^$3|yC&r<;?OjcC*lM1w-49R6LY*&Quc(;n2xfrye^ zRAy)X$A(c1rnh$Uf`)NJ%A*QeFO&Xx_(RC(J&i;!F8JO#n|dG zl90}Hft!%YO?^|PUQ3XB-vFf2B(}(v_4MBJC0F;@?^MNNpUe)Ws^e$Yg@SCTPsc5^ z_pJ@4xLSda)uo@Nu(*v9{PE=6T1*iI3OT!fYZQ+9?vzNFm@+3S;pzL&wi9n_)Ow+n z-ba{T^rmp^X$nhRp^6-)(Oy9d< z^R}z}mHRWzk^ND!)@!=?;I*e|F|_hefpcEQCn4s@*p@$q2r|=;n(w;o(}<>xxZS={!ZR_zRuOC z87mByCQ{u$E|6AQo3c(@3;u2z9snH$*ST~5UHQh$Q^xLRSx~WfE*P}CoNb%R!|tS; z;0kIrgm(+EV5!T=CvQt_b;sCduX?Px!!lp<33O~m*UQ(Nb=g%rDudyeX^S0h@wiu9LvZT^>$yI<_Bi_FypB@_XMdJGHgcWJ&sY8fSS!kk65_1zrtF}7Q@XWJ@zY(d{{wB1e;n}$Pijm4t z%tutEt~!v;{5?V>$kTpkw4a8lqWIZAK$X#km4i#)??;L$2WJ6#-f!%FsF^Xhjo`eg zx>nDj>EnfB@H3F@U^0u2y1z6k{S5QwW#*em7-!>Mv75rxs$S$#&sB;j=V{s&{z3t< z+!kQ2ca-V*ld;IC{qrvpUW+(cmeW$Wd^T%55J-N9!K+9!%>X_djjT<5q@+m}+ME z9gXdK<=qaImH+0)Z_lk-~TE_n-k3Ej6<%`_eE17hJQR z>`*Rui=dn4pUlCl(M4JF{E%LsE{58Ua>Ok>5Rio4;o(v$&E84`*1h+f3+kj#heV4p zkzvb=b8*k5>fRM!Py?1o_fPXsTzfp`wj|^@xTzo}y_jIS5>Fc|egTp!s`_KvCuJDm z9JP{Q-tS1>pMItu%u^Ci;@$W?O*tp1&b8_OXxVUN;PNR9Xm9uAb+MKj^H*RXXSgwG zNU{*I%!Mn4pdfY`b8H0*tfNcXD&XHXi#-TB`tO$#b&kYXGVD+4&3O1pYuy$D`T^r# zRp3BU_FwOn(F;)hB8kXh+FxfU|2>Ji3zQW~LX6i_(~k(0eRLxZR={-NVVFF7kvC!; zEubosn*ImP%k95$n)}ccrY2IBkUftOevNbd*LU-( zkBIqOD~oRg`9F!+c-&6w!^6X!#Y}FU*B6-=IzDFsRW>60^wf-=Z3g|0&~7o<^Tz)W zx+qoGQ&W^v4ymkaxIFCnzMgfi>`~tnt>1+NM}Aa`EHFawJ#Y9yu|vzC?#O^0FhJ!? zG)((My5i16ESy-b!a={}Ae`iRFctT}9}VPM5S1=m6=`-s`*h#9eGKk;>OMFdTtI(; za^Q-Zh?3C=smlXSBooMLNs?4a)PmGu(HM!V`m*WmCQiG*M)Iz?3ATt3DXF3!Ji{P! zDI4qO#qHePH<_i(UkcAi&T3PJ)DM1OQE2f?ima#Sw zqx{6$IG4g`q#h9+k%SI9W-p|K_W;dhH=7kpZ^e(31@<>tml=aoM>T zg$jt-D79JV-D^HKKsN&*DPo9|RKv+=-gM~z@9mHvE3&ISnOXXoEqjj?gDn3v(ib!q zm}u1~+4>p!>-6JZ0!whX*Vqb*HPbVwou%7nDHO7`tE-Vd$MY4T9uAi0qliTV1+72! zMszQ6bFP?z_n^f4=P_~=yD^BfCn>YnVdXYvPKg>w)Dcn8Wr*2hN1YM@nvGGza&zdqad9@zwFykzqYEl=tfJqnu*|?% z@Uk!Sz#1>Q=M_g@&p1Bm4s2uV)l{#pb;pcBeN%mBNq9cz;hyg(6v4{O)XaW5d+X?w zj_Y>SD$?Ej1ef!Ix>p5dH%3ks=hsE$UVEmmM2+o5n&YlLr4r%r@lH^fB$-}8cCMlm zb#~_8G3kC9_HAzhoL25_0!B=4p^Lb>?L94k{&2~DFf6GyV9W9Gq5fV$Yi zR2S;us_zC3+ToPO7>Ca>E){1rYN1a%I&dP2dN>-dFPJ5$tR|Mtt=Eyz+ki6D^_Pkj zoV9lRFO(&_3X{F55dQ09tNYWW;VPlFfSdj|wYc0+43^HI7DU-m42xIZd*Am)ViX9r z4lJL;R6(f{db+A4H)6Lk0|8?e?k*;H=OQKuKNMm}QOklp;{IRG7sz|fYt;t}d&A#J zkAP8WM^l8Eji@_AhNDw`n(w1bmeY`f^?GBF2i%|A{fQUrl`bS3@lIUB?9n0B)HB<6 zfeqe6Hfv!j#twH}HLBQQW(1S%E7+PW%Kd>Mu6tkU_Yt*Y_7{UUs#0i+23~G3?a~6n z>Y6AquO2>BW$FOt&OcbH6f`5uZI`PS@;ol^LD2Vtl_?YYDO!*t&}sv$IQ+F-;E|pV z{Y^&=#~Vl&+ubIFiA+kHqvEgm4F=z>EGhPpz96lcvVO}Lc?|14LJfWz+f5GWBZb_atzVPw5Qsdbf@Iew9 zEV6&Mr@Zub?2sl(m`@!)+!&AgoRlcZ$bzKf#_?NOPEwLc-Vs86LC8Iu3qq00mrrZd zm=s?53=ptm&=~D=1J!!m5$;w8!2RNA#g~@$eZ7-vd#agcKG;{m_gjZi6mX!&siRK8 z*jIkTY@DM8Jq$R;;S~Ia)p%zoBA`cQlG2f8Zju{r=E3LkWu}tHpV%Koqn;S&IqsQG zDgAiU5~6;Go9Bc9t=;yodh>mD?@CaVHGuo-!~F})zSSYsS|?%lAE?t4Gb zW-0EqAp7SqY}ef%g(GBs@%UG{(aS<;71S9aZ|F3&GVHYTLdE7dAbji8@cEIa_1_1` zA3peZQuAId;6#f5X5acx%$@u zgie2NTK1nu%^=!KMmC~XwU%BFK@x<^^8FUlZE7B7Xj{0lAk=Ml{uk6ZZU)YN-dLU~ zk0@9AGG9WHEwk@R9aqE70TS!F#+Thr^kJbAW_PjqUqV8Te=Y>AKMjF{A_sI;@iFWm zXeTG?NM4vRZyd`%as8JqMenllw?G9xE~x<}#MYJ@u@hzU7OmRM zfWRXm6L{G$Gn!1Eg5J38>(LqPd0zQU8^O!ZD8NXmvf8kyV78ToE5=eKvEQnAh{qg> z+u1dqRtw?A2D-4{_dyPP{BUz?y=?eUbh+qLAh-%5p*d0IzxltIUux7837xq#mNAN~zO`Tu<4s~=Lci1j7dLGPSUoY`8NJ^V(zJf;>z=vmS{v-tpc z=N9#vB)vzPKFHe;%Bu9LgQ2zbldDF=|C|Th^5@P{aKmqF?;CFh*y|@qk{~8&$Hcm0 zr+ewk`Jk+rradSXup;J)ci*zxriK6Vc zKfW@IiNV107tH$Kpy;$74Zbu%jBzIZ;c_xL!3Vt^(zpFiE!GC|>`$RUeW*b^?aip~ Qvs+*?5{ly0q6UHg2R%YkvH$=8 literal 0 HcmV?d00001 diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..d2e3a85 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,99 @@ +body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif} +.container{padding:16px} +.topbar{position:sticky;top:0;display:flex;flex-wrap:wrap;gap:12px;align-items:center;padding:10px 14px;background:#111;color:#fff;z-index:5} +.brand{font-weight:700;letter-spacing:.2px} +.search{display:flex;gap:8px;align-items:center;flex:1;min-width:220px} +.search input{width:100%;max-width:520px;padding:8px 10px;border-radius:10px;border:1px solid #333;background:#0e0e0e;color:#fff} +.search button{padding:8px 12px;border-radius:10px;border:1px solid #333;background:#1d1d1d;color:#fff} +.topbar nav a{color:#fff;text-decoration:none;margin:0 8px} +.toggles{display:flex;gap:10px;align-items:center} +.toggles button{padding:7px 10px;border-radius:10px;border:1px solid #333;background:#1d1d1d;color:#fff} +.footer{margin-top:20px;} + +.row{display:flex;gap:12px;align-items:center} +.between{justify-content:space-between} +.actions{display:flex;gap:10px;flex-wrap:wrap} + +h1{margin:10px 0 14px;font-size:22px} +.card{background:#151515;border:1px solid #2a2a2a;border-radius:14px;padding:14px;margin:12px 0;color:#e6e6e6} +.subtle{color:#aaa;font-size:12px;margin-top:4px} +.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace} +.right{text-align:right} +.empty{margin-top:18px;color:#aaa} + +.flashes{margin:10px 0 14px} +.flash{padding:10px 12px;border-radius:12px;margin:8px 0;border:1px solid #2a2a2a} +.flash.ok{background:#0f2a17;color:#cfe} +.flash.err{background:#2a1010;color:#ffd4d4} +.flash.warn{background:#2a2410;color:#fff0c2} + +.btn{display:inline-block;padding:8px 12px;border-radius:12px;border:1px solid #333;background:#1d1d1d;color:#fff;text-decoration:none;cursor:pointer} +.btn.primary{background:#0b2a5a;border-color:#214d9a} +.btn.danger{background:#3a1414;border-color:#6a2a2a} +.btn.small{padding:6px 10px;border-radius:10px;font-size:13px} + +.table{width:100%;border-collapse:collapse;background:#151515;color:#e6e6e6;border:1px solid #2a2a2a;border-radius:14px;overflow:hidden} +.table th,.table td{border-bottom:1px solid #2a2a2a;padding:12px 10px;vertical-align:top;line-height:1.25} +.table thead th{position:sticky;top: calc(var(--topbar-h, 54px) + 8px);background:#121212;z-index:3;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#bbb} +.apex-row td{background:#101010;color:#fff} +.details-row td{background:#0f0f0f} +.details{display:flex;flex-wrap:wrap;gap:14px;font-size:13px;color:#ddd} +.hidden{display:none} + +.legend{display:flex;gap:14px;align-items:center;flex-wrap:wrap;color:#bbb;margin:10px 0 12px;font-size:13px} +.dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid #444;margin-right:6px;vertical-align:middle} +.dot-blank{background:transparent} +.dot-green{background:#2ecc71;border-color:#2ecc71} +.dot-red{background:#ff4d4d;border-color:#ff4d4d} +.dot-yellow{background:#f1c40f;border-color:#f1c40f} +.dot-orange{background:#ff8c1a;border-color:#ff8c1a} + +.form label{display:block;font-size:12px;color:#bbb;margin:8px 0 6px} +.form input,.form textarea{width:100%;padding:9px 10px;border-radius:12px;border:1px solid #333;background:#0e0e0e;color:#fff;box-sizing:border-box} +.form textarea{resize:vertical} +.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px} +.checkbox{display:flex;gap:10px;align-items:center;color:#ddd} +.ok{color:#86efac} +.err{color:#fca5a5} +.warn{color:#fde68a;margin-top:6px} + +.theme-light .topbar{background:#f5f5f5;color:#111} +.theme-light .topbar nav a{color:#111} +.theme-light .search input{background:#fff;color:#111;border-color:#ddd} +.theme-light .search button,.theme-light .toggles button,.theme-light .btn{background:#fff;color:#111;border-color:#ddd} +.theme-light .table{background:#fff;color:#111;border-color:#ddd} +.theme-light .table th,.theme-light .table td{border-bottom:1px solid #eee} +.theme-light .table thead th{background:#fafafa;color:#555} +.theme-light .card{background:#fff;color:#111;border-color:#ddd} +.theme-light .form input,.theme-light .form textarea{background:#fff;color:#111;border-color:#ddd} + +.mask .mono, .mask td.mono {filter: blur(4px)} + + +/* FIX: prevent first row from sitting under the header row */ +table tbody::before { + content: ""; + display: block; + height: 60px; +} + + +/* Icon inside +Add Host button */ +.btn-icon-img{width:18px;height:18px;vertical-align:middle;margin-right:8px;filter:brightness(1.2)} + +/* Make hostname clickable for edit (mobile-friendly) */ +.hostlink{color:inherit;text-decoration:none} +.hostlink:hover{text-decoration:underline} + + +/* v3.1 - hosts header logo + inline actions */ +.add-host-group{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.add-host-logo-link{display:inline-flex;align-items:center} +.add-host-logo{width:48px;height:48px;display:block} +form.inline{display:inline;margin:0} +.footer{margin-top:20px;} +.footer-right{display:flex;gap:8px;align-items:center;opacity:0.9} +.footer-right .sep{opacity:0.6} + + +.footer-center{width:100%; text-align:center; opacity:.75; font-size:12px; padding:10px 0;} diff --git a/app/static/ufo-box.png b/app/static/ufo-box.png new file mode 100644 index 0000000000000000000000000000000000000000..4f0f6bf7cfbda9e5fff2d802dc372943d1630beb GIT binary patch literal 13854 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 Qvs+*?5{ly0q6UHg2R%YkvH$=8 literal 0 HcmV?d00001 diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..049390d --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,83 @@ + + + + + + outsidethebox.top - host registry + + + + + +
+
Host Registry
+ + + + + +
+ + +
+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat,msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+ +
+ + + + + + + diff --git a/app/templates/bulk_hosts.html b/app/templates/bulk_hosts.html new file mode 100644 index 0000000..119aab1 --- /dev/null +++ b/app/templates/bulk_hosts.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block content %} +

Bulk Add

+ +
+

+ Paste subdomains for a base zone. This does not discover subdomains automatically; it creates entries from what you provide. +

+ +
+
+
+ + +
+
+ +
+
+ + + + +
+ Optional defaults to apply to all created hosts +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + Back +
+
+
+{% endblock %} diff --git a/app/templates/edit_host.html b/app/templates/edit_host.html new file mode 100644 index 0000000..e3f5c4d --- /dev/null +++ b/app/templates/edit_host.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} +{% block content %} +
+

{% if is_new %}Add Host{% else %}Edit Host{% endif %}

+
+ Back + {% if not is_new %} +
+ +
+ {% endif %} +
+
+ +
+
+
+ + +
If you fill FQDN, zone/sub can auto-fill when you save.
+
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ +
+
+ {% if host.ssl_expires_at %}SSL expires: {{ host.ssl_expires_at.strftime("%Y-%m-%d") }}{% endif %} + {% if host.last_check_at %} • last check: {{ host.last_check_at.strftime("%Y-%m-%d %H:%M") }}{% endif %} + {% if host.last_error %}
Last error: {{ host.last_error }}
{% endif %} +
+ +
+
+{% endblock %} + + diff --git a/app/templates/health.html b/app/templates/health.html new file mode 100644 index 0000000..06d4d82 --- /dev/null +++ b/app/templates/health.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block content %} +

Health

+ +
+
DB: {% if db_ok %}OK{% else %}DOWN{% endif %}
+
Hosts: {{ host_count }}
+ {% if (not db_ok) and db_err %} +
Error: {{ db_err }}
+ {% endif %} +
+ +
+

System

+
Uptime: {{ sys_uptime }}
+
App Uptime: {{ app_uptime }}
+
Load: {{ load_avg }}
+
Memory: {{ mem }}
+
Disk: {{ disk }}
+
+{% endblock %} diff --git a/app/templates/hosts.html b/app/templates/hosts.html new file mode 100644 index 0000000..2b65463 --- /dev/null +++ b/app/templates/hosts.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} +{% block content %} +
+

Hosts

+ +
+ +
+ + + + + + Add Host + +
+ +
+ + Export CSV +
+ +
+
+ +
+ not configured + online + offline / error + SSL expiring ≤ 30d + host expiring ≤ 30d +
+ + + + + + + + + + + + + + + + + + + + + {% for r in rows %} + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
HostnameClientEmailCountrySSL ExpHost ExpPackageDNSPublic IPPrivate IPPVE
+ {{ r.fqdn }} + {% if r.sub %} +
sub: {{ r.sub }}
+ {% else %} +
apex
+ {% endif %} +
{{ r.client_name or "" }}{{ r.email or "" }}{{ r.country or "" }}{{ r.ssl_expires_str }}{{ r.host_expires_str }}{{ r.package_type or "" }}{{ r.dns_provider or "" }}{{ r.public_ip or "" }}{{ r.private_ip or "" }}{{ r.pve_host or "" }} + + Edit +
+ +{% if not rows %} +
No hosts yet. Click “Add Host” to start.
+{% endif %} + + +{% endblock %} diff --git a/app/templates/import_hosts.html b/app/templates/import_hosts.html new file mode 100644 index 0000000..59bbb34 --- /dev/null +++ b/app/templates/import_hosts.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block content %} +
+

Import Hosts

+ +
+ +
+

Upload a CSV file with columns matching the export format. Import will upsert by fqdn.

+
+ + +
+
+ +
+

CSV columns

+
zone, sub, fqdn, monitor_enabled, public_ip, private_ip, pve_host, client_name, email, country, package_type, dns_provider, host_expires_at, ssl_expires_at, notes
+
+{% endblock %} diff --git a/bin/backup_now.sh b/bin/backup_now.sh new file mode 100644 index 0000000..7122f9d --- /dev/null +++ b/bin/backup_now.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +CONF="/opt/db-admin/app/config.json" +DB_USER=$(jq -r '.db.user' "$CONF") +DB_PASS=$(jq -r '.db.password' "$CONF") +DB_NAME=$(jq -r '.db.name' "$CONF") +OUT_DIR="/var/backups/db-admin" +mkdir -p "$OUT_DIR" +TS=$(date +"%Y%m%d-%H%M%S") +OUT="$OUT_DIR/${DB_NAME}-${TS}.sql.gz" +mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" | gzip -9 > "$OUT" +echo "Backup written to $OUT" diff --git a/db-admin-sslcheck.service b/db-admin-sslcheck.service new file mode 100644 index 0000000..a866a69 --- /dev/null +++ b/db-admin-sslcheck.service @@ -0,0 +1,12 @@ +[Unit] +Description=db-admin weekly SSL/status check +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=www-data +Group=www-data +WorkingDirectory=/opt/outsidethedb/app +Environment="PATH=/opt/outsidethedb/venv/bin" +ExecStart=/opt/outsidethedb/venv/bin/python /opt/outsidethedb/app/sslcheck.py diff --git a/db-admin-sslcheck.timer b/db-admin-sslcheck.timer new file mode 100644 index 0000000..09f647f --- /dev/null +++ b/db-admin-sslcheck.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run db-admin SSL/status check weekly + +[Timer] +OnCalendar=weekly +Persistent=true +RandomizedDelaySec=900 + +[Install] +WantedBy=timers.target diff --git a/db-admin.service b/db-admin.service new file mode 100644 index 0000000..0aa3722 --- /dev/null +++ b/db-admin.service @@ -0,0 +1,13 @@ +[Unit] +Description=db-admin (Flask) +After=network.target +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/outsidethedb/app +Environment="PATH=/opt/outsidethedb/venv/bin" +ExecStart=/opt/outsidethedb/venv/bin/gunicorn -w 2 -b 0.0.0.0:8080 app:app --timeout 60 +Restart=always +[Install] +WantedBy=multi-user.target diff --git a/installer.sh b/installer.sh new file mode 100644 index 0000000..11b38af --- /dev/null +++ b/installer.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ $EUID -eq 0 ]] || { echo "Run as root"; exit 1; } + +echo "=== db-admin installer v2.5.1 (one-click deps) ===" +FREE_KB=$(df --output=avail / | tail -1) +(( FREE_KB >= 1000000 )) || { echo "Not enough disk space"; exit 1; } + +echo "[+] Installing OS dependencies" +export DEBIAN_FRONTEND=noninteractive +apt-get update -y +apt-get install -y \ + python3-venv python3-pip jq \ + mariadb-server mariadb-client \ + ca-certificates openssl curl + +# Ensure DB service is up (non-fatal if inside minimal container without systemd) +systemctl enable --now mariadb >/dev/null 2>&1 || true + +command -v mysql >/dev/null || { echo "ERROR: mysql client not found after install"; exit 1; } + + +# --- Interactive settings --- +# Defaults are sensible for Proxmox/LXC deployments behind a webfront (mintme): +# - DB on localhost +# - gunicorn bound to LAN (0.0.0.0:8080) +# - no nginx/apache in the container +# +# You only need to enter: DB password, web auth user/pass, and FQDN. + +DB_NAME="db_admin" +DB_HOST="127.0.0.1" +DB_USER="db-user" + +# Prompt helpers +ask_required () { + local prompt="$1" + local def="$2" + local var + while true; do + if [[ -n "$def" ]]; then + read -rp "$prompt [$def]: " var || true + var="${var:-$def}" + else + read -rp "$prompt: " var || true + fi + var="$(echo -n "$var" | xargs)" || true + if [[ -n "$var" ]]; then + echo "$var" + return 0 + fi + echo " -> Required. Please enter a value." >&2 + done +} + +ask_optional () { + local prompt="$1" + local def="$2" + local var + read -rp "$prompt [$def]: " var || true + var="${var:-$def}" + echo "$var" +} + +DB_PASS="$(ask_required "Database password (will be created for user $DB_USER)" "")" +AUTH_USER="$(ask_required "Web auth username" "db-user")" +AUTH_PASS="$(ask_required "Web auth password" "")" +FQDN="$(ask_required "FQDN (e.g. data.outsidethebox.top)" "")" + +WEBSRV="none" +BIND_ADDR="0.0.0.0:8080" +INSTALL_DIR="/opt/outsidethedb" + +echo +echo "--- Summary ---" +echo +echo "--- Summary ---" +echo " DB name: $DB_NAME" +echo " DB host: $DB_HOST" +echo " DB user: $DB_USER" +echo " FQDN: $FQDN" +echo " Webserver: $WEBSRV" +echo " Bind: $BIND_ADDR" +echo " Install dir: $INSTALL_DIR" +echo "--------------" +echo "Proceeding..." + +mkdir -p "$INSTALL_DIR" +cp -r app "$INSTALL_DIR/" +cp VERSION "$INSTALL_DIR/" +cp requirements.txt "$INSTALL_DIR/" +mkdir -p "$INSTALL_DIR/bin" "$INSTALL_DIR/log" +cp bin/backup_now.sh "$INSTALL_DIR/bin/" +chown -R www-data:www-data "$INSTALL_DIR" + +echo "[+] Setting up Python venv" +python3 -m venv "$INSTALL_DIR/venv" +"$INSTALL_DIR/venv/bin/pip" install --upgrade pip +"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" + +echo "[+] Writing app config" +jq --arg host "$DB_HOST" --arg name "$DB_NAME" --arg user "$DB_USER" --arg pass "$DB_PASS" --arg fqdn "$FQDN" --arg authu "$AUTH_USER" --arg authp "$AUTH_PASS" \ + '.db.host=$host | .db.name=$name | .db.user=$user | .db.password=$pass | .site.fqdn=$fqdn | .site.auth_user=$authu | .site.auth_pass=$authp' \ + "$INSTALL_DIR/app/config.json" > "$INSTALL_DIR/app/config.json.tmp" +mv "$INSTALL_DIR/app/config.json.tmp" "$INSTALL_DIR/app/config.json" +chown www-data:www-data "$INSTALL_DIR/app/config.json" + +echo "[*] DB schema ..." +SQL_ADMIN="root" +SQL_ADMIN_PASS="" + +TMP=$(mktemp) +sed "s/DATABASE_NAME/$DB_NAME/g" template.sql > "$TMP" + +if [[ -n "$SQL_ADMIN_PASS" ]]; then + MYSQL_AUTH=(-u"$SQL_ADMIN" -p"$SQL_ADMIN_PASS") +else + MYSQL_AUTH=(-u"$SQL_ADMIN") +fi + +mysql "${MYSQL_AUTH[@]}" -e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +mysql "${MYSQL_AUTH[@]}" -e "CREATE USER IF NOT EXISTS '$DB_USER'@'%' IDENTIFIED BY '$DB_PASS'; GRANT ALL ON \`$DB_NAME\`.* TO '$DB_USER'@'%'; FLUSH PRIVILEGES;" +mysql "${MYSQL_AUTH[@]}" "$DB_NAME" < "$TMP" +rm -f "$TMP" + +echo "[+] Installing systemd units" +cp db-admin.service /etc/systemd/system/db-admin.service +cp db-admin-sslcheck.service /etc/systemd/system/db-admin-sslcheck.service +cp db-admin-sslcheck.timer /etc/systemd/system/db-admin-sslcheck.timer + +# Apply bind address to systemd unit +sed -i "s/-b 127\\.0\\.0\\.1:8080/-b ${BIND_ADDR}/" /etc/systemd/system/db-admin.service + +systemctl daemon-reload +systemctl enable --now db-admin.service +systemctl enable --now db-admin-sslcheck.timer +systemctl restart db-admin.service + +if [[ "$WEBSRV" == "nginx" ]]; then + echo "[+] Installing nginx (optional)" + apt-get install -y nginx apache2-utils + htpasswd_file="/etc/nginx/.db-admin-htpasswd" + htpasswd -b -c "$htpasswd_file" "$AUTH_USER" "$AUTH_PASS" + cat > "/etc/nginx/sites-available/${FQDN}.conf" < "/etc/apache2/sites-available/${FQDN}.conf" < + ServerName $FQDN + ErrorLog \${APACHE_LOG_DIR}/${FQDN}-error.log + CustomLog \${APACHE_LOG_DIR}/${FQDN}-access.log combined + + + AuthType Basic + AuthName "Restricted" + AuthUserFile $htpasswd_file + Require valid-user + + + ProxyPreserveHost On + ProxyPass / http://127.0.0.1:8080/ + ProxyPassReverse / http://127.0.0.1:8080/ + +APC + a2ensite "${FQDN}.conf" + apache2ctl configtest && systemctl reload apache2 + echo "Done. Visit: http://$FQDN" +else + echo "Done. No webserver installed in this container (recommended behind your webfront)." + echo "Proxy your webfront to: http://:8080" + echo "Example: proxy_pass http://192.168.0.24:8080;" +fi diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f868aa8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask==2.3.3 +gunicorn==23.0.0 +PyMySQL==1.1.0 +requests==2.32.3 diff --git a/template.sql b/template.sql new file mode 100644 index 0000000..85adc1a --- /dev/null +++ b/template.sql @@ -0,0 +1,47 @@ +CREATE DATABASE IF NOT EXISTS `DATABASE_NAME` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `DATABASE_NAME`; + +-- Hosts registry for outsidethebox.top (and other zones) +CREATE TABLE IF NOT EXISTS hosts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + + -- DNS grouping + zone VARCHAR(255) NOT NULL, -- e.g. etica-stats.org + sub VARCHAR(255) NULL, -- e.g. explorer (NULL/empty for apex) + fqdn VARCHAR(512) NOT NULL, -- e.g. explorer.etica-stats.org (unique) + + -- Status / monitoring + monitor_enabled TINYINT(1) NOT NULL DEFAULT 0, + status_code_http INT NULL, + status_code_https INT NULL, + status ENUM('up','down','unknown') NOT NULL DEFAULT 'unknown', + last_check_at DATETIME NULL, + last_error TEXT NULL, + + -- SSL + ssl_expires_at DATETIME NULL, + + -- Host/service expiry (your "hostname expires") + host_expires_at DATETIME NULL, + + -- Infra placement + public_ip VARCHAR(64) NULL, + private_ip VARCHAR(64) NULL, + pve_host VARCHAR(128) NULL, + + -- Business fields + client_name VARCHAR(255) NULL, + email VARCHAR(255) NULL, + country VARCHAR(128) NULL, + package_type VARCHAR(128) NULL, + dns_provider VARCHAR(128) NULL, + notes TEXT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uq_hosts_fqdn (fqdn), + KEY idx_zone_sub (zone, sub), + KEY idx_client (client_name) +); +