20 changed files with 1654 additions and 0 deletions
@ -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 => "<seconds> <idle_seconds>" |
||||
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/<int:hid>/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/<int:hid>/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) |
||||
@ -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" |
||||
} |
||||
} |
||||
@ -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() |
||||
|
After Width: | Height: | Size: 14 KiB |
@ -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;} |
||||
|
After Width: | Height: | Size: 14 KiB |
@ -0,0 +1,83 @@
|
||||
<!doctype html> |
||||
<html> |
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
||||
<title>outsidethebox.top - host registry</title> |
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon"> |
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='ufo-box.png') }}"> |
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> |
||||
</head> |
||||
<body class="theme-dark"> |
||||
<header class="topbar"> |
||||
<div class="brand">Host Registry</div> |
||||
|
||||
<form class="search" action="{{ url_for('hosts') }}" method="get"> |
||||
<input type="text" name="q" value="{{ q|default('') }}" placeholder="Search hosts, clients, IPs..."> |
||||
<button type="submit">Search</button> |
||||
</form> |
||||
|
||||
<nav> |
||||
<a href="{{ url_for('hosts') }}">Hosts</a> |
||||
<a href="{{ url_for('hosts_import') }}">Import</a> |
||||
<a href="{{ url_for('hosts_bulk') }}">Bulk</a> |
||||
<a href="{{ url_for('health') }}">Health</a> |
||||
</nav> |
||||
|
||||
<div class="toggles"> |
||||
<button id="toggleTheme" type="button">Toggle Light</button> |
||||
<label><input type="checkbox" id="masker"> Mask</label> |
||||
</div> |
||||
</header> |
||||
|
||||
<main class="container"> |
||||
{% with messages = get_flashed_messages(with_categories=true) %} |
||||
{% if messages %} |
||||
<div class="flashes"> |
||||
{% for cat,msg in messages %} |
||||
<div class="flash {{ cat }}">{{ msg }}</div> |
||||
{% endfor %} |
||||
</div> |
||||
{% endif %} |
||||
{% endwith %} |
||||
{% block content %}{% endblock %} |
||||
</main> |
||||
|
||||
<footer class="footer"> |
||||
<div class="footer-center"> |
||||
{{ version }} • © {{ year }} outsidethebox.top • uptime: {{ sys_uptime }} • DB: {% if db_ok %}OK{% else %}DOWN{% endif %} ({{ host_count }} hosts) |
||||
</div> |
||||
</footer> |
||||
|
||||
<script> |
||||
(function(){ |
||||
const btn=document.getElementById('toggleTheme'); |
||||
const body=document.body; |
||||
btn && btn.addEventListener('click', ()=>{ |
||||
body.classList.toggle('theme-light'); |
||||
body.classList.toggle('theme-dark'); |
||||
}); |
||||
|
||||
const masker=document.getElementById('masker'); |
||||
masker && masker.addEventListener('change', ()=>{ |
||||
document.body.classList.toggle('mask', masker.checked); |
||||
}); |
||||
})(); |
||||
</script> |
||||
|
||||
<script> |
||||
(function() { |
||||
function setTopbarVar() { |
||||
var tb = document.querySelector('.topbar'); |
||||
if (!tb) return; |
||||
var h = tb.getBoundingClientRect().height; |
||||
document.documentElement.style.setProperty('--topbar-h', h + 'px'); |
||||
} |
||||
window.addEventListener('load', setTopbarVar); |
||||
window.addEventListener('resize', setTopbarVar); |
||||
setTopbarVar(); |
||||
})(); |
||||
</script> |
||||
|
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %} |
||||
{% block content %} |
||||
<h1>Bulk Add</h1> |
||||
|
||||
<div class="card"> |
||||
<p class="subtle"> |
||||
Paste subdomains for a base zone. This does <strong>not</strong> discover subdomains automatically; it creates entries from what you provide. |
||||
</p> |
||||
|
||||
<form method="post"> |
||||
<div class="grid2"> |
||||
<div> |
||||
<label>Base zone <span class="req">(required)</span></label> |
||||
<input name="zone" placeholder="etica-stats.org" value="{{ zone|default('') }}" required> |
||||
</div> |
||||
<div class="row"> |
||||
<label style="margin-top:28px"> |
||||
<input type="checkbox" name="include_apex" {% if include_apex %}checked{% endif %}> |
||||
Include apex (zone itself) |
||||
</label> |
||||
</div> |
||||
</div> |
||||
|
||||
<label>Subdomains (one per line, or comma-separated)</label> |
||||
<textarea name="subs" rows="8" placeholder="api |
||||
explorer |
||||
rpc">{{ subs|default('') }}</textarea> |
||||
|
||||
<details style="margin-top:10px"> |
||||
<summary>Optional defaults to apply to all created hosts</summary> |
||||
<div class="grid3" style="margin-top:10px"> |
||||
<div> |
||||
<label>Client</label> |
||||
<input name="client_name" value="{{ defaults.client_name|default('') }}"> |
||||
</div> |
||||
<div> |
||||
<label>Email</label> |
||||
<input name="email" value="{{ defaults.email|default('') }}"> |
||||
</div> |
||||
<div> |
||||
<label>Country</label> |
||||
<input name="country" value="{{ defaults.country|default('') }}"> |
||||
</div> |
||||
<div> |
||||
<label>Package</label> |
||||
<input name="package_type" value="{{ defaults.package_type|default('') }}"> |
||||
</div> |
||||
<div> |
||||
<label>DNS provider</label> |
||||
<input name="dns_provider" value="{{ defaults.dns_provider|default('') }}"> |
||||
</div> |
||||
<div> |
||||
<label>PVE host</label> |
||||
<input name="pve_host" value="{{ defaults.pve_host|default('') }}"> |
||||
</div> |
||||
<div> |
||||
<label>Public IP</label> |
||||
<input name="public_ip" value="{{ defaults.public_ip|default('') }}"> |
||||
</div> |
||||
<div> |
||||
<label>Private IP</label> |
||||
<input name="private_ip" value="{{ defaults.private_ip|default('') }}"> |
||||
</div> |
||||
<div class="row"> |
||||
<label style="margin-top:28px"> |
||||
<input type="checkbox" name="monitor_enabled" {% if defaults.monitor_enabled %}checked{% endif %}> |
||||
Enable monitoring |
||||
</label> |
||||
</div> |
||||
</div> |
||||
</details> |
||||
|
||||
<div class="actions" style="margin-top:12px"> |
||||
<button class="btn" type="submit">Create / Update</button> |
||||
<a class="btn ghost" href="{{ url_for('hosts') }}">Back</a> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
{% endblock %} |
||||
@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %} |
||||
{% block content %} |
||||
<div class="row between"> |
||||
<h1>{% if is_new %}Add Host{% else %}Edit Host{% endif %}</h1> |
||||
<div class="actions"> |
||||
<a class="btn" href="{{ url_for('hosts') }}">Back</a> |
||||
{% if not is_new %} |
||||
<form method="post" action="{{ url_for('host_delete', hid=host.id) }}" onsubmit="return confirm('Delete this host?');" style="display:inline"> |
||||
<button class="btn danger" type="submit">Delete</button> |
||||
</form> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
|
||||
<form class="form" method="post"> |
||||
<div class="grid2"> |
||||
<div> |
||||
<label>FQDN (full hostname) <span class="req">(required)</span></label> |
||||
<input name="fqdn" value="{{ host.fqdn or '' }}" placeholder="explorer.etica-stats.org" required> |
||||
<div class="subtle">If you fill FQDN, zone/sub can auto-fill when you save.</div> |
||||
</div> |
||||
<div> |
||||
<label>Monitoring enabled</label> |
||||
<label class="checkbox"> |
||||
<input type="checkbox" name="monitor_enabled" {% if host.monitor_enabled %}checked{% endif %}> |
||||
Enable status + SSL checks |
||||
</label> |
||||
</div> |
||||
|
||||
<div> |
||||
<label>Zone (base domain)</label> |
||||
<input name="zone" value="{{ host.zone or '' }}" placeholder="etica-stats.org"> |
||||
</div> |
||||
<div> |
||||
<label>Subdomain (blank for apex)</label> |
||||
<input name="sub" value="{{ host.sub or '' }}" placeholder="explorer"> |
||||
</div> |
||||
|
||||
<div> |
||||
<label>Client name</label> |
||||
<input name="client_name" value="{{ host.client_name or '' }}"> |
||||
</div> |
||||
<div> |
||||
<label>Email</label> |
||||
<input name="email" value="{{ host.email or '' }}"> |
||||
</div> |
||||
|
||||
<div> |
||||
<label>Country</label> |
||||
<input name="country" value="{{ host.country or '' }}"> |
||||
</div> |
||||
<div> |
||||
<label>Package type</label> |
||||
<input name="package_type" value="{{ host.package_type or '' }}" placeholder="Hosting / Hosting+Monitoring / etc"> |
||||
</div> |
||||
|
||||
<div> |
||||
<label>DNS provider</label> |
||||
<input name="dns_provider" value="{{ host.dns_provider or '' }}" placeholder="Namecheap / Cloudflare / etc"> |
||||
</div> |
||||
<div> |
||||
<label>Host expires (orange warning ≤30d)</label> |
||||
<input type="datetime-local" name="host_expires_at" value="{{ host.host_expires_at_html or '' }}"> |
||||
</div> |
||||
|
||||
<div> |
||||
<label>Public IP</label> |
||||
<input name="public_ip" value="{{ host.public_ip or '' }}"> |
||||
</div> |
||||
<div> |
||||
<label>Private IP</label> |
||||
<input name="private_ip" value="{{ host.private_ip or '' }}"> |
||||
</div> |
||||
|
||||
<div> |
||||
<label>PVE host</label> |
||||
<input name="pve_host" value="{{ host.pve_host or '' }}" placeholder="pve1 / pve2 / nl-pve"> |
||||
</div> |
||||
<div> |
||||
<label>Notes</label> |
||||
<textarea name="notes" rows="5">{{ host.notes or '' }}</textarea> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="row between"> |
||||
<div class="subtle"> |
||||
{% 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 %}<div class="warn">Last error: {{ host.last_error }}</div>{% endif %} |
||||
</div> |
||||
<button class="btn primary" type="submit">Save</button> |
||||
</div> |
||||
</form> |
||||
{% endblock %} |
||||
|
||||
<script> |
||||
function buildFqdn(){ |
||||
const zone=document.querySelector('input[name="zone"]'); |
||||
const sub=document.querySelector('input[name="sub"]'); |
||||
const fqdn=document.querySelector('input[name="fqdn"]'); |
||||
if(!zone||!fqdn) return; |
||||
const z=(zone.value||'').trim().toLowerCase().replace(/^\.+|\.+$/g,''); |
||||
const s=(sub&&sub.value?sub.value:'').trim().toLowerCase().replace(/^\.+|\.+$/g,''); |
||||
if(!fqdn.value.trim() && z){ |
||||
fqdn.value = (s? (s+'.') : '') + z; |
||||
} |
||||
} |
||||
['change','blur','keyup'].forEach(ev=>{ |
||||
const z=document.querySelector('input[name="zone"]'); |
||||
const s=document.querySelector('input[name="sub"]'); |
||||
if(z) z.addEventListener(ev, buildFqdn); |
||||
if(s) s.addEventListener(ev, buildFqdn); |
||||
}); |
||||
</script> |
||||
@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %} |
||||
{% block content %} |
||||
<h1>Health</h1> |
||||
|
||||
<div class="card"> |
||||
<div><strong>DB:</strong> {% if db_ok %}OK{% else %}DOWN{% endif %}</div> |
||||
<div><strong>Hosts:</strong> {{ host_count }}</div> |
||||
{% if (not db_ok) and db_err %} |
||||
<div class="mono subtle"><strong>Error:</strong> {{ db_err }}</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<div class="card"> |
||||
<h2>System</h2> |
||||
<div><strong>Uptime:</strong> {{ sys_uptime }}</div> |
||||
<div><strong>App Uptime:</strong> {{ app_uptime }}</div> |
||||
<div><strong>Load:</strong> {{ load_avg }}</div> |
||||
<div><strong>Memory:</strong> {{ mem }}</div> |
||||
<div><strong>Disk:</strong> {{ disk }}</div> |
||||
</div> |
||||
{% endblock %} |
||||
@ -0,0 +1,113 @@
|
||||
{% extends "base.html" %} |
||||
{% block content %} |
||||
<div class="row between"> |
||||
<h1>Hosts</h1> |
||||
|
||||
<div class="actions"> |
||||
|
||||
<div class="add-host-group"> |
||||
<a class="add-host-logo-link" href="https://outsidethebox.top/" target="_blank" rel="noopener"> |
||||
<img src="{{ url_for('static', filename='ufo-box.png') }}" class="add-host-logo" alt="outsidethebox.top"> |
||||
</a> |
||||
|
||||
<a class="btn" href="{{ url_for('host_new') }}">+ Add Host</a> |
||||
|
||||
<form class="inline" action="{{ url_for('backup_db') }}" method="post" onsubmit="return confirm('Create a DB backup now?');"> |
||||
<button class="btn" type="submit">Backup DB</button> |
||||
</form> |
||||
|
||||
<a class="btn" href="{{ url_for('hosts_export') }}">Export CSV</a> |
||||
</div> |
||||
|
||||
</div> |
||||
</div> |
||||
|
||||
<div class="legend"> |
||||
<span class="dot dot-blank"></span> not configured |
||||
<span class="dot dot-green"></span> online |
||||
<span class="dot dot-red"></span> offline / error |
||||
<span class="dot dot-yellow"></span> SSL expiring ≤ 30d |
||||
<span class="dot dot-orange"></span> host expiring ≤ 30d |
||||
</div> |
||||
|
||||
<table class="table"> |
||||
<thead> |
||||
<tr> |
||||
<th></th> |
||||
<th>Hostname</th> |
||||
<th>Client</th> |
||||
<th>Email</th> |
||||
<th>Country</th> |
||||
<th>SSL Exp</th> |
||||
<th>Host Exp</th> |
||||
<th>Package</th> |
||||
<th>DNS</th> |
||||
<th>Public IP</th> |
||||
<th>Private IP</th> |
||||
<th>PVE</th> |
||||
<th></th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for r in rows %} |
||||
<tr class="{% if not r.sub %}apex-row{% endif %}"> |
||||
<td><span class="dot {{ r.dot }}"></span></td> |
||||
<td class="mono"> |
||||
{{ r.fqdn }} |
||||
{% if r.sub %} |
||||
<div class="subtle">sub: {{ r.sub }}</div> |
||||
{% else %} |
||||
<div class="subtle">apex</div> |
||||
{% endif %} |
||||
</td> |
||||
<td>{{ r.client_name or "" }}</td> |
||||
<td class="mono">{{ r.email or "" }}</td> |
||||
<td>{{ r.country or "" }}</td> |
||||
<td class="mono">{{ r.ssl_expires_str }}</td> |
||||
<td class="mono">{{ r.host_expires_str }}</td> |
||||
<td>{{ r.package_type or "" }}</td> |
||||
<td>{{ r.dns_provider or "" }}</td> |
||||
<td class="mono">{{ r.public_ip or "" }}</td> |
||||
<td class="mono">{{ r.private_ip or "" }}</td> |
||||
<td class="mono">{{ r.pve_host or "" }}</td> |
||||
<td class="right"> |
||||
<button class="btn small ghost" type="button" data-toggle="details-{{ r.id }}">Details</button> |
||||
<a class="btn small" href="{{ url_for('host_edit', hid=r.id) }}">Edit</a> |
||||
</td> |
||||
</tr> |
||||
<tr id="details-{{ r.id }}" class="details-row hidden"> |
||||
<td></td> |
||||
<td colspan="11"> |
||||
<div class="details"> |
||||
<div><strong>HTTP:</strong> {{ r.status_code_http if r.status_code_http is not none else "" }}</div> |
||||
<div><strong>HTTPS:</strong> {{ r.status_code_https if r.status_code_https is not none else "" }}</div> |
||||
<div><strong>Last check:</strong> {{ r.last_check_str }}</div> |
||||
{% if r.last_error %} |
||||
<div><strong>Last error:</strong> <span class="mono">{{ r.last_error }}</span></div> |
||||
{% endif %} |
||||
{% if r.notes %} |
||||
<div><strong>Notes:</strong> {{ r.notes }}</div> |
||||
{% endif %} |
||||
</div> |
||||
</td> |
||||
<td></td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
|
||||
{% if not rows %} |
||||
<div class="empty">No hosts yet. Click “Add Host” to start.</div> |
||||
{% endif %} |
||||
|
||||
<script> |
||||
document.querySelectorAll('[data-toggle]').forEach(btn => { |
||||
btn.addEventListener('click', () => { |
||||
const id = btn.getAttribute('data-toggle'); |
||||
const row = document.getElementById(id); |
||||
if (!row) return; |
||||
row.classList.toggle('hidden'); |
||||
}); |
||||
}); |
||||
</script> |
||||
{% endblock %} |
||||
@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %} |
||||
{% block content %} |
||||
<div class="row between"> |
||||
<h1>Import Hosts</h1> |
||||
<div class="actions"> |
||||
<a class="btn" href="{{ url_for('hosts_export') }}">Download CSV template/export</a> |
||||
<a class="btn" href="{{ url_for('hosts') }}">Back</a> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="card"> |
||||
<p>Upload a CSV file with columns matching the export format. Import will upsert by <code>fqdn</code>.</p> |
||||
<form method="post" enctype="multipart/form-data"> |
||||
<input type="file" name="csvfile" accept=".csv,text/csv" required> |
||||
<button class="btn primary" type="submit">Import CSV</button> |
||||
</form> |
||||
</div> |
||||
|
||||
<div class="card"> |
||||
<h3>CSV columns</h3> |
||||
<div class="mono">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</div> |
||||
</div> |
||||
{% endblock %} |
||||
@ -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" |
||||
@ -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 |
||||
@ -0,0 +1,10 @@
|
||||
[Unit] |
||||
Description=Run db-admin SSL/status check weekly |
||||
|
||||
[Timer] |
||||
OnCalendar=weekly |
||||
Persistent=true |
||||
RandomizedDelaySec=900 |
||||
|
||||
[Install] |
||||
WantedBy=timers.target |
||||
@ -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 |
||||
@ -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" <<NGX |
||||
server { |
||||
server_name $FQDN; |
||||
access_log /var/log/nginx/${FQDN}.access.log; |
||||
error_log /var/log/nginx/${FQDN}.error.log; |
||||
|
||||
location / { |
||||
auth_basic "Restricted"; |
||||
auth_basic_user_file $htpasswd_file; |
||||
|
||||
proxy_http_version 1.1; |
||||
proxy_set_header Host \$host; |
||||
proxy_set_header X-Real-IP \$remote_addr; |
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; |
||||
proxy_set_header X-Forwarded-Proto \$scheme; |
||||
|
||||
proxy_pass http://127.0.0.1:8080; |
||||
client_max_body_size 20m; |
||||
} |
||||
|
||||
listen 80; |
||||
} |
||||
NGX |
||||
ln -sf "/etc/nginx/sites-available/${FQDN}.conf" "/etc/nginx/sites-enabled/${FQDN}.conf" |
||||
nginx -t && systemctl reload nginx |
||||
echo "Done. Visit: http://$FQDN" |
||||
elif [[ "$WEBSRV" == "apache" ]]; then |
||||
echo "[+] Installing apache (optional)" |
||||
apt-get install -y apache2 apache2-utils |
||||
a2enmod proxy proxy_http headers auth_basic |
||||
htpasswd_file="/etc/apache2/.db-admin-htpasswd" |
||||
htpasswd -b -c "$htpasswd_file" "$AUTH_USER" "$AUTH_PASS" |
||||
cat > "/etc/apache2/sites-available/${FQDN}.conf" <<APC |
||||
<VirtualHost *:80> |
||||
ServerName $FQDN |
||||
ErrorLog \${APACHE_LOG_DIR}/${FQDN}-error.log |
||||
CustomLog \${APACHE_LOG_DIR}/${FQDN}-access.log combined |
||||
|
||||
<Location "/"> |
||||
AuthType Basic |
||||
AuthName "Restricted" |
||||
AuthUserFile $htpasswd_file |
||||
Require valid-user |
||||
</Location> |
||||
|
||||
ProxyPreserveHost On |
||||
ProxyPass / http://127.0.0.1:8080/ |
||||
ProxyPassReverse / http://127.0.0.1:8080/ |
||||
</VirtualHost> |
||||
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://<container-ip>:8080" |
||||
echo "Example: proxy_pass http://192.168.0.24:8080;" |
||||
fi |
||||
@ -0,0 +1,4 @@
|
||||
Flask==2.3.3 |
||||
gunicorn==23.0.0 |
||||
PyMySQL==1.1.0 |
||||
requests==2.32.3 |
||||
@ -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) |
||||
); |
||||
|
||||
Loading…
Reference in new issue