#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GBTI AD Lockout Investigator (gbtiguyana.net)
Pure-LDAP edition - NO PowerShell (nothing for AV/EDR to flag).
- Login with AD admin credentials
- Enter username -> query EVERY DC for badPwdCount / lockoutTime -> see which
  server the lockouts come from
- One click unlock (clears lockoutTime on the PDC Emulator)
Deps: pip install ldap3 dnspython
"""
import tkinter as tk
from tkinter import ttk, messagebox
import threading

DOMAIN = "gbtiguyana.net"
NETBIOS = "GBTI"
BASE_DN = "DC=gbtiguyana,DC=net"


def dc_servers():
    servers = [DOMAIN]
    try:
        import dns.resolver
        for r in dns.resolver.resolve(f"_ldap._tcp.{DOMAIN}", "SRV"):
            host = str(r.target).rstrip(".")
            if host not in servers:
                servers.append(host)
    except Exception:
        pass
    return servers


def try_bind(server, user, pwd):
    from ldap3 import Server, Connection
    srv = Server(server, connect_timeout=6, get_info=None)
    try:
        c = Connection(srv, user=user, password=pwd, auto_bind=True,
                       authentication="SIMPLE", receive_timeout=30)
        return c, None
    except Exception as e:
        return None, str(e)


def friendly(err):
    err = str(err)
    low = err.lower()
    if "invalidcredentials" in low or "data 52e" in low:
        return "Wrong username or password (domain is .NET - check spelling)"
    if "timeout" in low or "timed out" in low or "unreachable" in low or "refused" in low:
        return "Cannot reach that domain controller (network/VPN?)"
    if "usernotfound" in low or "data 525" in low:
        return "User not found"
    if "data 533" in low:
        return "Account disabled"
    if "data 775" in low or "accountlocked" in low:
        return "That account is itself LOCKED OUT"
    return err[:160]


class App:
    def __init__(self, root):
        self.root = root
        self.conn = None
        self.pwd = None
        self.pdc = None
        root.title(f"GBTI AD Lockout Investigator - {DOMAIN}")
        root.geometry("860x620")
        root.minsize(760, 540)
        self.build_login()

    # ---------- login ----------
    def build_login(self):
        for w in self.root.winfo_children():
            w.destroy()
        f = ttk.Frame(self.root, padding=40)
        f.pack(expand=True)
        ttk.Label(f, text="GBTI AD Lockout Investigator",
                  font=("Segoe UI", 16, "bold")).grid(row=0, column=0, columnspan=2, pady=(0, 4))
        ttk.Label(f, text=f"Domain: {DOMAIN}   (pure LDAP - no PowerShell)",
                  foreground="#555").grid(row=1, column=0, columnspan=2, pady=(0, 24))
        ttk.Label(f, text="Admin username:").grid(row=2, column=0, sticky="e", pady=4)
        self.lu = ttk.Entry(f, width=34)
        self.lu.grid(row=2, column=1, pady=4)
        self.lu.insert(0, "administrator")
        ttk.Label(f, text="Password:").grid(row=3, column=0, sticky="e", pady=4)
        self.lp = ttk.Entry(f, width=34, show="*")
        self.lp.grid(row=3, column=1, pady=4)
        self.btn = ttk.Button(f, text="Sign in", command=self.do_login)
        self.btn.grid(row=4, column=1, sticky="w", pady=(18, 0))
        self.status = ttk.Label(f, text="", foreground="#666", wraplength=460)
        self.status.grid(row=5, column=0, columnspan=2, pady=(12, 0))
        self.root.bind("<Return>", lambda e: self.do_login())

    def do_login(self):
        u = self.lu.get().strip()
        p = self.lp.get()
        if not u or not p:
            messagebox.showwarning("Missing", "Enter username and password")
            return
        user = u if ("@" in u or "\\" in u) else f"{u}@{DOMAIN}"
        user = (user.replace(".neet@", ".net@")
                    .replace("@gbtiguyana.neet", f"@{DOMAIN}"))
        self.btn.state(["disabled"])
        self.status.config(text="Connecting to domain controllers...", foreground="#666")

        def work():
            last = None
            for server in dc_servers():
                conn, err = try_bind(server, user, p)
                if conn:
                    self.root.after(0, lambda c=conn, s=server, pw=p:
                                    self.build_main(c, s, pw=pw))
                    return
                last = err
            self.root.after(0, lambda: (
                self.btn.state(["!disabled"]),
                self.status.config(text="Login failed: " + friendly(last or "unreachable"),
                                   foreground="#c0392b")))
        threading.Thread(target=work, daemon=True).start()

    # ---------- main ----------
    def build_main(self, conn, server, pw=None):
        self.conn = conn
        if pw:
            self.pwd = pw
        if getattr(self, "_pending_pwd", None):
            self.pwd = self._pending_pwd
            self._pending_pwd = None
        self.pdc = self.find_pdc(conn) or server
        for w in self.root.winfo_children():
            w.destroy()
        f = ttk.Frame(self.root, padding=20)
        f.pack(expand=True, fill="both")
        ttk.Label(f, text="AD account lockout lookup",
                  font=("Segoe UI", 15, "bold")).grid(row=0, column=0, columnspan=3, sticky="w")
        ttk.Label(f, text="Username:").grid(row=1, column=0, sticky="e", pady=6)
        self.un = ttk.Entry(f, width=26)
        self.un.grid(row=1, column=1, sticky="w", padx=6)
        ttk.Button(f, text="Search lockouts", command=self.on_search).grid(
            row=1, column=2, sticky="w")
        self.tree = ttk.Treeview(f, columns=("Server", "Detail"), show="headings", height=9)
        self.tree.heading("Server", text="Server (DC)")
        self.tree.heading("Detail", text="badPwdCount / lockoutTime / verdict")
        self.tree.column("Server", width=240)
        self.tree.column("Detail", width=500)
        self.tree.grid(row=2, column=0, columnspan=3, sticky="nsew", pady=8)
        self.info = tk.Text(f, height=7, wrap="word")
        self.info.grid(row=3, column=0, columnspan=3, sticky="nsew")
        ttk.Button(f, text="Unlock account", command=self.on_unlock).grid(
            row=4, column=0, sticky="w", pady=8)
        self.stat2 = ttk.Label(f, text=f"PDC Emulator: {self.pdc}", foreground="#555")
        self.stat2.grid(row=5, column=0, columnspan=3, sticky="w")
        f.rowconfigure(2, weight=1)
        f.columnconfigure(2, weight=1)

    def find_pdc(self, conn):
        try:
            conn.search(BASE_DN, "(objectClass=domainDNS)", attributes=["fSMORoleOwner"],
                        search_scope="BASE")
            if conn.entries:
                fsmo = str(conn.entries[0]["fSMORoleOwner"])
                for part in fsmo.split(","):
                    if part.startswith("CN=") and "NTDS" not in part:
                        return part[3:]
        except Exception:
            pass
        return None

    def set_info(self, txt):
        self.info.delete("1.0", "end")
        self.info.insert("1.0", txt)

    def lookup_dn(self, conn, username):
        from ldap3 import SUBTREE
        conn.search(BASE_DN,
                    f"(&(objectClass=user)(|(sAMAccountName={username})"
                    f"(userPrincipalName={username}@{DOMAIN})))",
                    attributes=["distinguishedName"], search_scope=SUBTREE, size_limit=5)
        return conn.entries[0].entry_dn if conn.entries else None

    # ---------- search every DC ----------
    def on_search(self):
        username = self.un.get().strip()
        if not username:
            messagebox.showinfo("Input", "Enter a username")
            return
        self.set_info("Querying every domain controller (badPwdCount / lockoutTime)...")
        u, p = self.conn.user, self.pwd

        def work():
            rows = []
            for srv in dc_servers():
                conn, err = try_bind(srv, u, p)
                if not conn:
                    continue
                dn = self.lookup_dn(conn, username)
                if not dn:
                    continue
                conn.search(dn, "(objectClass=*)", search_scope="BASE",
                            attributes=["lockoutTime", "badPwdCount"])
                if not conn.entries:
                    continue
                e = conn.entries[0]
                try:
                    bad = e["badPwdCount"].value or 0
                except Exception:
                    bad = 0
                try:
                    lt = e["lockoutTime"].value
                except Exception:
                    lt = None
                if lt and getattr(lt, "year", 1601) > 1601:
                    lt_txt = lt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
                    verdict = "LOCKED OUT" if bad >= 5 else "recent bad logons"
                else:
                    lt_txt = "never"
                    verdict = "clean"
                rows.append((srv, f"badPwdCount={bad}   lockoutTime={lt_txt}   -> {verdict}"))

            origin_rows = [r for r in rows if "LOCKED OUT" in r[1]]
            recent_rows = [r for r in rows if "recent bad" in r[1]]

            def paint():
                for i in self.tree.get_children():
                    self.tree.delete(*self.tree.get_children())
                if origin_rows:
                    for srv, det in origin_rows:
                        self.tree.insert("", "end", values=(srv, det))
                    self.set_info(
                        "LOCKOUT ORIGIN FOUND. The server above holds the bad password attempts "
                        "that keep locking the account - usually a device with stale credentials "
                        "(mapped drive, saved password, phone/email app). "
                        "Unlock below clears it domain-wide via the PDC.")
                elif recent_rows:
                    for srv, det in recent_rows:
                        self.tree.insert("", "end", values=(srv, det))
                    self.set_info(
                        "No active lock right now, but these servers saw recent bad password "
                        "attempts - the device causing them is still trying. The next burst "
                        "will re-lock the account from these servers.")
                else:
                    self.tree.insert("", "end", values=(
                        "NO ACTIVE LOCKOUT",
                        "No bad password attempts found on any DC right now"))
                    self.set_info(
                        "No active lockout detected - badPwdCount is 0 everywhere. "
                        "If the user gets locked again, run this search the moment it "
                        "happens: the DC with climbing badPwdCount is the culprit, and "
                        "the device with stale credentials sits at that site.")

            self.root.after(0, paint)
        threading.Thread(target=work, daemon=True).start()

    def on_unlock(self):
        username = self.un.get().strip()
        if not username:
            messagebox.showinfo("Input", "Enter a username")
            return
        if not messagebox.askyesno("Confirm", f"Unlock account '{username}'?"):
            return

        def work():
            pdc = self.pdc or dc_servers()[0]
            conn, err = try_bind(pdc, self.conn.user, self.pwd)
            if not conn:
                self.root.after(0, lambda: self.set_info(
                    "Could not reach the PDC:\n" + friendly(err)))
                return
            dn = self.lookup_dn(conn, username)
            if not dn:
                self.root.after(0, lambda: self.set_info(f"User '{username}' not found."))
                return
            ok = conn.modify(dn, {"lockoutTime": [(2, [b"0"])]})
            if ok:
                self.root.after(0, lambda: messagebox.showinfo(
                    "Unlocked", f"Account '{username}' unlocked (lockoutTime cleared on PDC;\nreplicates domain-wide)."))
            else:
                self.root.after(0, lambda: self.set_info(
                    "Unlock failed: " + str(conn.result)))
        threading.Thread(target=work, daemon=True).start()


if __name__ == "__main__":
    root = tk.Tk()
    App(root)
    root.mainloop()