NextDNS Manager

Manage Allow/Deny lists and view info in a nice looking GUI for NextDNS

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         NextDNS Manager 
// @description  Manage Allow/Deny lists and view info in a nice looking GUI for NextDNS 
// @namespace    https://nextdns.io/
// @license      MIT
// @version      2.8
// @match        https://my.nextdns.io/*
// @grant        none
// ==/UserScript==

(async function() {
    'use strict';

    const parseDomains = text => [...new Set((text.match(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi) || []).map(d => d.toLowerCase()))];

    let stopImport = false;

    const getProfiles = async () => {
        try {
            const res = await fetch("https://api.nextdns.io/accounts/@me?withProfiles=true", {
                credentials: "include"
            });
            if (!res.ok) return null;
            const data = await res.json();
            return data.profiles || [];
        } catch (e) {
            return null;
        }
    };

    const getProfileInfo = async profileId => {
        const profiles = await getProfiles();
        if (!profiles) return null;
        return profiles.find(p => p.id === profileId);
    };

    const getProfileIdFromURL = () => {
        const match = window.location.pathname.match(/^\/([a-z0-9]+)\//i);
        return match ? match[1] : null;
    };

    const getListTypeFromURL = () => {
        if (window.location.pathname.includes("/allowlist")) return "allowlist";
        if (window.location.pathname.includes("/denylist")) return "denylist";
        return null;
    };

    const fetchList = async (profileId, listType) => {
        try {
            const res = await fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}`, {
                credentials: "include"
            });
            const data = await res.json();
            return data.data || [];
        } catch (e) {
            return [];
        }
    };

    const addDomain = async (profileId, listType, domain) => {
        let attempts = 0;
        while (attempts < 5) {
            try {
                const res = await fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}`, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json"
                    },
                    credentials: "include",
                    body: JSON.stringify({
                        id: domain,
                        active: true
                    })
                });
                if (res.status === 429 || !res.ok) {
                    attempts++;
                    await new Promise(r => setTimeout(r, 2000));
                } else {
                    return true;
                }
            } catch (e) {
                attempts++;
                await new Promise(r => setTimeout(r, 2000));
            }
        }
        console.warn(`Failed to add ${domain} after 5 retries`);
        return false;
    };

    const removeDomain = async (profileId, listType, domain) => fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}/hex:${Array.from(domain).map(c=>c.charCodeAt(0).toString(16)).join('')}`, {
        method: "DELETE",
        credentials: "include"
    });

    const toggleDomainActive = async (profileId, listType, domain, active) => fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}/hex:${Array.from(domain).map(c=>c.charCodeAt(0).toString(16)).join('')}`, {
        method: "PATCH",
        headers: {
            "Content-Type": "application/json"
        },
        credentials: "include",
        body: JSON.stringify({
            active
        })
    });

    let guiBox = null;

    const createGUI = async () => {
        if (guiBox) guiBox.remove();

        guiBox = document.createElement("div");
        guiBox.style.cssText = `
            position: fixed;
            top: 100px;
            right: 20px;
            z-index: 2147483647;
            width: 450px;
            background: #111;
            color: #fff;
            padding: 12px;
            border-radius: 8px;
            font-family: monospace;
            box-shadow: 0 0 15px rgba(0,0,0,0.8);
            display: flex;
            flex-direction: column;
            max-height: 650px;
        `;

        guiBox.innerHTML = `
            <div id="ndns-header" style="cursor:move; font-weight:bold; display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
                <div>
                    <div id="ndns-profile-name" style="font-size:16px;"></div>
                    <div id="ndns-profile-info" style="font-size:11px;opacity:0.7;"></div>
                    <div id="ndns-list-type" style="font-size:13px;font-weight:bold;margin-top:2px;"></div>
                </div>
                <button id="ndns-toggle" style="background:#222;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">Hide</button>
            </div>
            <div id="ndns-content" style="flex:1; display:flex; flex-direction:column; overflow:hidden;"></div>
        `;
        document.documentElement.appendChild(guiBox);

        const toggleBtn = guiBox.querySelector("#ndns-toggle");
        const contentDiv = guiBox.querySelector("#ndns-content");
        const profileNameEl = guiBox.querySelector("#ndns-profile-name");
        const profileInfoEl = guiBox.querySelector("#ndns-profile-info");
        const listTypeEl = guiBox.querySelector("#ndns-list-type");

        let hidden = false;
        toggleBtn.onclick = () => {
            hidden = !hidden;
            contentDiv.style.display = hidden ? "none" : "flex";
            toggleBtn.innerText = hidden ? "Show" : "Hide";
        };

        // Dragging
        const header = guiBox.querySelector("#ndns-header");
        let offsetX = 0,
            offsetY = 0,
            dragging = false;
        header.onmousedown = e => {
            dragging = true;
            offsetX = e.clientX - guiBox.getBoundingClientRect().left;
            offsetY = e.clientY - guiBox.getBoundingClientRect().top;
        };
        document.onmouseup = () => dragging = false;
        document.onmousemove = e => {
            if (dragging) {
                guiBox.style.left = e.clientX - offsetX + "px";
                guiBox.style.top = e.clientY - offsetY + "px";
            }
        };

        const refreshGUI = async () => {
            const currentProfileId = getProfileIdFromURL();
            const currentListType = getListTypeFromURL();
            const onSetupPage = window.location.pathname.includes("/setup");

            // Check login
            const profiles = await getProfiles();
            if (!profiles) {
                profileNameEl.innerText = "Not logged in";
                profileInfoEl.innerText = "";
                listTypeEl.innerText = "";
                contentDiv.innerHTML = "<div style='color:red;'>Please log in to view your profiles and lists.</div>";
                document.title = "NextDNS - Not logged in";
                return;
            }

            // Update profile info
            const profile = await getProfileInfo(currentProfileId);
            if (profile) {
                profileNameEl.innerText = profile.name;
                profileInfoEl.innerText = `ID: ${profile.id} | Role: ${profile.role}`;
                document.title = `NextDNS - ${profile.name} (${currentListType ? currentListType.toUpperCase() : ''})`;
            } else {
                profileNameEl.innerText = "Profile not found";
                profileInfoEl.innerText = "";
            }

            // Setup page
            if (onSetupPage && profile) {
                const res = await fetch(`https://api.nextdns.io/profiles/${currentProfileId}/setup`, {
                    credentials: "include"
                });
                const data = await res.json();
                listTypeEl.innerText = "SETUP";
                listTypeEl.style.color = "#66ccff";
                contentDiv.innerHTML = `
                    <div style="margin-bottom:6px;font-weight:bold;">Setup Info</div>
                    <div><span style="color:#ffcc00">IPv4:</span> ${data.data.ipv4.join(", ") || "None"}</div>
                    <div><span style="color:#ffcc00">IPv6:</span> ${data.data.ipv6.join(", ") || "None"}</div>
                    <div><span style="color:#66ff66">Linked IP:</span> ${data.data.linkedIp?.ip || "None"}</div>
                    <div><span style="color:#66ff66">Servers:</span> ${data.data.linkedIp?.servers.join(", ") || "None"}</div>
                    <div><span style="color:#66ccff">DNSCrypt:</span> ${data.data.dnscrypt || "None"}</div>
                `;
            }
            // Allow/Deny list page
            else if (currentListType && profile) {
                listTypeEl.innerText = currentListType.toUpperCase();
                listTypeEl.style.color = currentListType === "denylist" ? "#ff4136" : "#2ecc40";

                contentDiv.innerHTML = `
    <div style="margin-bottom:6px; display:flex; align-items:center; gap:4px;">
        <button id="ndns-import" style="width:80px;background:#444;color:#fff;border:none;padding:6px;border-radius:4px;font-weight:bold;cursor:pointer;">Import</button>
        <div id="ndns-selected-file" style="flex:1; font-size:12px; color:#ccc; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">No file selected</div>
        <button id="ndns-stop" style="width:60px;background:#aa3333;color:#fff;border:none;padding:6px;border-radius:4px;font-weight:bold;cursor:pointer;">Stop</button>
    </div>
    <textarea id="ndns-bulk" placeholder="Paste multiple domains..." style="width:100%;height:80px;padding:6px;resize:none;background:#1c1c1c;color:#fff;border:none;border-radius:4px;margin-bottom:6px;"></textarea>
    <button id="ndns-add-bulk" style="width:100%;margin-bottom:6px;background:#333;color:#fff;border:none;padding:6px;border-radius:4px;font-weight:bold;cursor:pointer;transition:0.2s;">Add Domains</button>
    <div id="ndns-list" style="flex:1;overflow-y:auto;max-height:400px;"></div>
    <div id="ndns-status" style="margin-top:6px;font-size:12px;opacity:0.8;"></div>
`;


                const listDiv = contentDiv.querySelector("#ndns-list");
                const bulkInput = contentDiv.querySelector("#ndns-bulk");
                const addBulkBtn = contentDiv.querySelector("#ndns-add-bulk");
                const importBtn = contentDiv.querySelector("#ndns-import");
                const stopBtn = contentDiv.querySelector("#ndns-stop");
                const selectedFileLabel = contentDiv.querySelector("#ndns-selected-file");
                const status = contentDiv.querySelector("#ndns-status");

                addBulkBtn.onmouseenter = () => {
                    addBulkBtn.style.background = "#444";
                }
                addBulkBtn.onmouseleave = () => {
                    addBulkBtn.style.background = "#333";
                }
                importBtn.onmouseenter = () => {
                    importBtn.style.background = "#555";
                }
                importBtn.onmouseleave = () => {
                    importBtn.style.background = "#444";
                }
                stopBtn.onmouseenter = () => {
                    stopBtn.style.background = "#cc5555";
                }
                stopBtn.onmouseleave = () => {
                    stopBtn.style.background = "#aa3333";
                }

                stopBtn.onclick = () => {
                    stopImport = true;
                    status.innerText = "Import stopped.";
                };

                const refreshList = async () => {
                    const list = await fetchList(currentProfileId, currentListType);
                    listDiv.innerHTML = "";
                    const borderColor = currentListType === "denylist" ? "rgb(255,65,54)" : "rgb(46,204,64)";
                    list.forEach(d => {
                        const item = document.createElement("div");
                        item.style.cssText = `
                            display:flex;
                            align-items:center;
                            justify-content:space-between;
                            padding:6px;
                            border-left:4px solid ${borderColor};
                            margin-bottom:4px;
                            background:#1a1a1a;
                            border-radius:4px;
                        `;
                        const domainContainer = document.createElement("div");
                        domainContainer.style.cssText = "display:flex;align-items:center;gap:6px;";
                        const img = document.createElement("img");
                        img.src = `https://favicons.nextdns.io/hex:${Array.from(d.id).map(c=>c.charCodeAt(0).toString(16)).join('')}@2x.png`;
                        img.style.cssText = "width:16px;height:16px;";
                        const span = document.createElement("span");
                        span.style.cssText = "word-break: break-all;";
                        span.innerText = d.id;
                        domainContainer.appendChild(img);
                        domainContainer.appendChild(span);

                        const controls = document.createElement("div");
                        controls.style.cssText = "display:flex;align-items:center;gap:6px;";
                        const checkbox = document.createElement("input");
                        checkbox.type = "checkbox";
                        checkbox.checked = d.active;
                        checkbox.onchange = async () => {
                            await toggleDomainActive(currentProfileId, currentListType, d.id, checkbox.checked);
                        };
                        const delBtn = document.createElement("button");
                        delBtn.innerText = "🗑️";
                        delBtn.style.cssText = "background:none;border:none;color:#fff;cursor:pointer;";
                        delBtn.onclick = async () => {
                            await removeDomain(currentProfileId, currentListType, d.id);
                            item.remove();
                        };
                        controls.appendChild(checkbox);
                        controls.appendChild(delBtn);

                        item.appendChild(domainContainer);
                        item.appendChild(controls);
                        listDiv.appendChild(item);
                    });
                };

                addBulkBtn.onclick = async () => {
                    const domains = parseDomains(bulkInput.value);
                    if (!domains.length) return alert("No valid domains found");
                    stopImport = false;
                    status.innerText = `Adding ${domains.length} domains...`;
                    for (let i = 0; i < domains.length; i++) {
                        if (stopImport) break;
                        await addDomain(currentProfileId, currentListType, domains[i]);
                        status.innerText = `Added ${i+1}/${domains.length}: ${domains[i]}`;
                        await new Promise(r => setTimeout(r, 500));
                    }
                    bulkInput.value = "";
                    if (!stopImport) status.innerText = `Done! Added ${domains.length} domains.`;
                    await refreshList();
                };

                importBtn.onclick = async () => {
                    const input = document.createElement("input");
                    input.type = "file";
                    input.accept = ".txt";
                    input.onchange = async () => {
                        if (!input.files.length) return;
                        const file = input.files[0];
                        selectedFileLabel.innerText = `Selected file: ${file.name}`;
                        const text = await file.text();
                        const domains = parseDomains(text).slice(0, 200);
                        if (domains.length === 0) return alert("No valid domains in file.");
                        stopImport = false;
                        status.innerText = `Importing ${domains.length} domains...`;
                        for (let i = 0; i < domains.length; i++) {
                            if (stopImport) break;
                            await addDomain(currentProfileId, currentListType, domains[i]);
                            status.innerText = `Imported ${i+1}/${domains.length}: ${domains[i]}`;
                            await new Promise(r => setTimeout(r, 500));
                        }
                        if (!stopImport) status.innerText = `Done! Imported ${domains.length} domains.`;
                        await refreshList();
                    };
                    input.click();
                };

                await refreshList();

                // Live sync
                const pageList = document.querySelector(".list-group.list-group-flush");
                if (pageList) {
                    const observer = new MutationObserver(() => {
                        const pageItems = Array.from(pageList.querySelectorAll(".list-group-item"));
                        const uiItems = Array.from(listDiv.querySelectorAll("div"));

                        const pageDomains = pageItems.map(item => {
                            const span = item.querySelector("span.notranslate");
                            if (!span) return null;
                            const domain = span.innerText.replace(/^\*\./, "").trim();
                            const active = parseFloat(item.querySelector(".flex-grow-1")?.style.opacity || "1") > 0.5;
                            return {
                                domain,
                                active
                            };
                        }).filter(d => d);

                        uiItems.forEach(uiItem => {
                            const domain = uiItem.querySelector("span").innerText;
                            if (!pageDomains.some(d => d.domain === domain)) uiItem.remove();
                        });

                        pageDomains.forEach(d => {
                            let uiItem = uiItems.find(i => i.querySelector("span").innerText === d.domain);
                            if (!uiItem) {
                                const item = document.createElement("div");
                                item.style.cssText = `
                                    display:flex;
                                    align-items:center;
                                    justify-content:space-between;
                                    padding:6px;
                                    border-left:4px solid ${currentListType==="denylist"?"rgb(255,65,54)":"rgb(46,204,64)"};
                                    margin-bottom:4px;
                                    background:#1a1a1a;
                                    border-radius:4px;
                                `;
                                const domainContainer = document.createElement("div");
                                domainContainer.style.cssText = "display:flex;align-items:center;gap:6px;";
                                const img = document.createElement("img");
                                img.src = `https://favicons.nextdns.io/hex:${Array.from(d.domain).map(c=>c.charCodeAt(0).toString(16)).join('')}@2x.png`;
                                img.style.cssText = "width:16px;height:16px;";
                                const span = document.createElement("span");
                                span.style.cssText = "word-break: break-all;";
                                span.innerText = d.domain;
                                domainContainer.appendChild(img);
                                domainContainer.appendChild(span);

                                const controls = document.createElement("div");
                                controls.style.cssText = "display:flex;align-items:center;gap:6px;";
                                const checkbox = document.createElement("input");
                                checkbox.type = "checkbox";
                                checkbox.checked = d.active;
                                checkbox.onchange = async () => {
                                    await toggleDomainActive(currentProfileId, currentListType, d.domain, checkbox.checked);
                                };
                                const delBtn = document.createElement("button");
                                delBtn.innerText = "🗑️";
                                delBtn.style.cssText = "background:none;border:none;color:#fff;cursor:pointer;";
                                delBtn.onclick = async () => {
                                    await removeDomain(currentProfileId, currentListType, d.domain);
                                    item.remove();
                                };
                                controls.appendChild(checkbox);
                                controls.appendChild(delBtn);

                                item.appendChild(domainContainer);
                                item.appendChild(controls);
                                listDiv.appendChild(item);
                            } else {
                                const checkbox = uiItem.querySelector("input[type=checkbox]");
                                if (checkbox) checkbox.checked = d.active;
                            }
                        });
                    });
                    observer.observe(pageList, {
                        childList: true,
                        subtree: true
                    });
                }
            }
        };

        await refreshGUI();

        let lastURL = location.href;
        setInterval(() => {
            if (location.href !== lastURL) {
                lastURL = location.href;
                refreshGUI();
                lastURL = location.href;
            }
        }, 1000);
    };

    setTimeout(createGUI, 1500);
})();