Browse Source

db-admin

master
def 2 weeks ago
parent
commit
5b29e96d6c
  1. 1
      VERSION
  2. 686
      app/app.py
  3. 21
      app/config.json
  4. 114
      app/sslcheck.py
  5. BIN
      app/static/favicon.ico
  6. 99
      app/static/style.css
  7. BIN
      app/static/ufo-box.png
  8. 83
      app/templates/base.html
  9. 79
      app/templates/bulk_hosts.html
  10. 114
      app/templates/edit_host.html
  11. 21
      app/templates/health.html
  12. 113
      app/templates/hosts.html
  13. 23
      app/templates/import_hosts.html
  14. 12
      bin/backup_now.sh
  15. 12
      db-admin-sslcheck.service
  16. 10
      db-admin-sslcheck.timer
  17. 13
      db-admin.service
  18. 202
      installer.sh
  19. 4
      requirements.txt
  20. 47
      template.sql

1
VERSION

@ -0,0 +1 @@
3.2.1

686
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 => "<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)

21
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"
}
}

114
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()

BIN
app/static/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

99
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;}

BIN
app/static/ufo-box.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

83
app/templates/base.html

@ -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>

79
app/templates/bulk_hosts.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 %}

114
app/templates/edit_host.html

@ -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>

21
app/templates/health.html

@ -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 %}

113
app/templates/hosts.html

@ -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 %}

23
app/templates/import_hosts.html

@ -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 %}

12
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"

12
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

10
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

13
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

202
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" <<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

4
requirements.txt

@ -0,0 +1,4 @@
Flask==2.3.3
gunicorn==23.0.0
PyMySQL==1.1.0
requests==2.32.3

47
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)
);
Loading…
Cancel
Save