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 0000000..4f0f6bf Binary files /dev/null and b/app/static/favicon.ico differ 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 0000000..4f0f6bf Binary files /dev/null and b/app/static/ufo-box.png differ 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) +); +