From cb33abff1393e366df79b85a7b207ac3dca8b7fd Mon Sep 17 00:00:00 2001 From: Sosokker Date: Tue, 22 Apr 2025 01:21:52 +0700 Subject: [PATCH] initial commit --- README.md | 33 ++++ main.js | 488 ++++++++++++++++++++++++++++++++++++++++++++++++++ static/ui.png | Bin 0 -> 11171 bytes 3 files changed, 521 insertions(+) create mode 100644 README.md create mode 100644 main.js create mode 100644 static/ui.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef15027 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Ome.tv IP Info Tool + +A Tampermonkey userscript that displays IP and location info (including organization) for Ome.tv users. It provides a fixed-size, draggable UI overlay with manual WebRTC hook setup. The script is intended for educational purposes only. + +## Disclaimer + +**This project is for educational purposes only.** + +The authors and contributors of this code are **not responsible for any misuse or illegal activity** that results from using this script. Use this tool responsibly and respect the privacy and rights of others. By using this script, you agree that you are solely responsible for your actions. + +## Features +- Shows peer IP and location details on Ome.tv +- Draggable, fixed-size overlay UI +- Manual and automatic IP info fetching +- Requires a free API key from [ipinfo.io](https://ipinfo.io/) + +![UI Screenshot](static/ui.png) + +## Usage + +1. **Install Tampermonkey** (or a compatible userscript manager) in your browser. (Or just manually add the script via console) +2. **Add this script** (`main.js`) to Tampermonkey. +3. **Get an API key** from [ipinfo.io](https://ipinfo.io/signup). +4. Open [Ome.tv](https://ome.tv/) in your browser. +5. Enter your ipinfo.io API key in the overlay UI. +6. The tool will display the peer's IP and location info when a connection is established. + +### Options +- **Auto-fetch:** Enable to automatically fetch details when a new IP is detected. +- **Manual fetch:** Use the UI to fetch details as needed. + +## License +MIT \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..3e4f14c --- /dev/null +++ b/main.js @@ -0,0 +1,488 @@ +// ==UserScript== +// @name Ome.tv IP Info Tool +// @namespace http://tampermonkey.net/ +// @version 1.0 +// @description Displays IP and location info (incl. Org) for Ome.tv users. +// @match https://ome.tv/* +// @grant none +// @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css +// ==/UserScript== + +(function() { + 'use strict'; + + // --- Global Variables --- + // Stores state like current IP, API key, auto-fetch preference, and map link. + let currentIP = null; + let apiKey = localStorage.getItem('omeTvIpToolApiKey') || ''; + let autoFetch = localStorage.getItem('omeTvIpToolAutoFetch') === 'true'; + let mapLink = null; + + // --- UI Creation --- + // Dynamically creates the HTML structure for the overlay window, + // applies CSS styles for appearance and layout (including fixed size), + // and attaches necessary event listeners to UI elements (inputs, buttons). + function createUI() { + const overlay = document.createElement('div'); + overlay.id = 'ipInfoOverlay'; + + overlay.innerHTML = ` +
Ome.tv IP Info (Drag Me)
+
+ +
+ + +
+
+
+ + +
+
+ Status: Waiting for connection... +
+
+ IP: - +
+
+ Details: - +
+
+ + + +
+ `; + + let targetElement = document.querySelector('main#about') || document.body; + targetElement.appendChild(overlay); + + // Add close button event + overlay.querySelector('#ipInfoOverlayClose').addEventListener('click', () => { + overlay.remove(); + }); + + const style = document.createElement('style'); + // --- CSS Modifications Below --- + style.textContent = ` + @import url('https://fonts.googleapis.com/css2?family=Segoe+UI&display=swap'); + + #ipInfoOverlay { + position: fixed; bottom: 15px; right: 15px; + width: 300px; height: 280px; overflow-y: auto; + background-color: #f0f0f0; border: 2px solid #0078D4; /* border-radius removed */ + padding: 10px 15px; z-index: 10000; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-size: 13px; + color: #333; box-shadow: 0 2px 5px rgba(0,0,0,0.2); + line-height: 1.4; display: flex; flex-direction: column; + } + #ipInfoOverlayTitle { + font-size: 16px; font-weight: bold; color: #005a9e; + margin-bottom: 10px; padding-bottom: 5px; border-bottom: 1px solid #ccc; + text-align: center; user-select: none; cursor: move; flex-shrink: 0; + } + .ipInfoSection { margin-bottom: 8px; flex-shrink: 0; } + .ipInfoSection label { display: inline-block; width: 80px; font-weight: 600; vertical-align: middle; } + .ipInfoSection input[type="checkbox"] { vertical-align: middle; margin-left: 5px; } + #ipInfoLocation { display: block; margin-left: 75px; line-height: 1.5; word-wrap: break-word; } + .apiKeyWrapper { display: inline-flex; align-items: center; border: 1px solid #ccc; border-radius: 3px; padding-right: 2px; background-color: #fff; max-width: calc(100% - 85px); } + #apiKeyInput { border: none; outline: none; padding: 4px 6px; flex-grow: 1; font-size: 12px; min-width: 80px; } + #ipInfoOverlay strong { font-weight: 600; display: inline-block; width: 70px; vertical-align: top; } + .ipInfoButtons { + margin-top: auto; padding-top: 10px; + border-top: 1px solid #ccc; flex-shrink: 0; + display: flex; /* Added for horizontal alignment */ + justify-content: flex-end; /* Added to push buttons right */ + } + .ipInfoBtn, .ipInfoBtn-Icon { padding: 5px 10px; margin-left: 5px; border: 1px solid #0078D4; background-color: #e1e1e1; color: #333; border-radius: 3px; cursor: pointer; font-size: 12px; transition: background-color 0.2s ease; } + .ipInfoBtn:hover:not(:disabled), .ipInfoBtn-Icon:hover { background-color: #cce4f7; } + .ipInfoBtn:disabled { cursor: not-allowed; opacity: 0.6; border-color: #ccc; background-color: #e9e9e9; } + .ipInfoBtn-Icon { background: none; border: none; padding: 4px; vertical-align: middle; line-height: 1; color: #0078D4; } + .ipInfoBtn-Icon i { font-size: 14px; } + `; + // --- End of CSS Modifications --- + document.head.appendChild(style); + + if (!document.querySelector('link[href*="font-awesome"]')) { + const faLink = document.createElement("link"); + faLink.rel = "stylesheet"; + faLink.href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"; + faLink.integrity = "sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="; + faLink.crossOrigin = "anonymous"; + faLink.referrerPolicy = "no-referrer"; + document.head.appendChild(faLink); + } + + const apiKeyInput = document.getElementById('apiKeyInput'); + const toggleApiKeyBtn = document.getElementById('toggleApiKey'); + const autoFetchCheckbox = document.getElementById('autoFetchCheckbox'); + const fetchDetailsBtn = document.getElementById('fetchDetailsBtn'); + const openMapBtn = document.getElementById('openMapBtn'); + const manualHookBtn = document.getElementById('manualHookBtn'); + + apiKeyInput.value = apiKey; + autoFetchCheckbox.checked = autoFetch; + + apiKeyInput.addEventListener('input', () => { + apiKey = apiKeyInput.value; + localStorage.setItem('omeTvIpToolApiKey', apiKey); + updateStatus(apiKey ? 'API Key set.' : 'API Key missing!', apiKey ? 'info' : 'warn'); + if (currentIP && apiKey && fetchDetailsBtn) { + fetchDetailsBtn.disabled = false; + } else if (!apiKey && fetchDetailsBtn) { + fetchDetailsBtn.disabled = true; + } + }); + + toggleApiKeyBtn.addEventListener('click', toggleApiKeyVisibility); + + autoFetchCheckbox.addEventListener('change', () => { + autoFetch = autoFetchCheckbox.checked; + localStorage.setItem('omeTvIpToolAutoFetch', autoFetch); + }); + + fetchDetailsBtn.addEventListener('click', () => { + if (currentIP) { + gather(currentIP); + } + }); + + openMapBtn.addEventListener('click', () => { + if (mapLink) { + openMap(mapLink); + } + }); + + manualHookBtn.addEventListener('click', () => { + console.log("Manual hook setup triggered."); + setupWebRTCHook(); + }); + + dragElement(overlay, document.getElementById('ipInfoOverlayTitle')); + } + + // --- Drag Element Functionality --- + // Makes the specified element (elmnt) draggable by its handle (dragHandle). + // It calculates the new position based on mouse movement and updates the + // element's top/left styles, ensuring it stays within viewport bounds. + function dragElement(elmnt, dragHandle) { + let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; + const header = dragHandle || elmnt; + + header.onmousedown = dragMouseDown; + + function dragMouseDown(e) { + e = e || window.event; + if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.tagName === 'LABEL') { + return; + } + e.preventDefault(); + pos3 = e.clientX; + pos4 = e.clientY; + document.onmouseup = closeDragElement; + document.onmousemove = elementDrag; + + if (elmnt.style.bottom || elmnt.style.right) { + const rect = elmnt.getBoundingClientRect(); + elmnt.style.top = rect.top + "px"; + elmnt.style.left = rect.left + "px"; + elmnt.style.bottom = ""; + elmnt.style.right = ""; + } + } + + function elementDrag(e) { + e = e || window.event; + e.preventDefault(); + pos1 = pos3 - e.clientX; + pos2 = pos4 - e.clientY; + pos3 = e.clientX; + pos4 = e.clientY; + let newTop = elmnt.offsetTop - pos2; + let newLeft = elmnt.offsetLeft - pos1; + + const maxX = window.innerWidth - elmnt.offsetWidth; + const maxY = window.innerHeight - elmnt.offsetHeight; + newLeft = Math.max(0, Math.min(newLeft, maxX)); + newTop = Math.max(0, Math.min(newTop, maxY)); + + elmnt.style.top = newTop + "px"; + elmnt.style.left = newLeft + "px"; + } + + function closeDragElement() { + document.onmouseup = null; + document.onmousemove = null; + } + } + + // --- Helper Functions --- + // Contains utility functions for updating specific parts of the UI (status, IP, location), + // toggling API key visibility, and opening the map link. + function updateStatus(message, type = 'info') { + const statusEl = document.getElementById('ipInfoStatus'); + if (statusEl) { + statusEl.textContent = message; + switch (type) { + case 'success': statusEl.style.color = 'green'; break; + case 'warn': statusEl.style.color = 'orange'; break; + case 'error': statusEl.style.color = 'red'; break; + default: statusEl.style.color = '#555'; + } + } else { + console.log(`Status [${type}]: ${message}`); + } + } + + function updateIpDisplay(ip) { + const ipEl = document.getElementById('ipInfoIP'); + if(ipEl) ipEl.textContent = ip || '-'; + } + + function updateLocationDisplay(detailsHtml) { + const locEl = document.getElementById('ipInfoLocation'); + if(locEl) locEl.innerHTML = detailsHtml || '-'; + } + + function toggleApiKeyVisibility() { + const apiKeyInput = document.getElementById('apiKeyInput'); + const icon = document.querySelector('#toggleApiKey i'); + if (apiKeyInput.type === 'password') { + apiKeyInput.type = 'text'; + icon.classList.remove('fa-eye'); + icon.classList.add('fa-eye-slash'); + } else { + apiKeyInput.type = 'password'; + icon.classList.remove('fa-eye-slash'); + icon.classList.add('fa-eye'); + } + } + + function openMap(link) { + if (link) { + window.open(link, '_blank'); + } + } + + // --- Core Logic (IP Details Fetching) --- + // Handles fetching detailed IP information from the ipinfo.io API using the provided IP and API key. + // Updates the UI with status messages, location details, and enables/disables buttons accordingly. + // Handles potential errors during the API call. + async function gather(ip) { + const fetchBtn = document.getElementById('fetchDetailsBtn'); + const mapBtn = document.getElementById('openMapBtn'); + mapLink = null; + + if (!apiKey) { + updateStatus('Error: ipinfo.io API Key is missing.', 'error'); + if (fetchBtn) fetchBtn.disabled = true; + return; + } + + updateStatus('Fetching details...', 'info'); + if (fetchBtn) fetchBtn.disabled = true; + if (mapBtn) mapBtn.disabled = true; + updateLocationDisplay('Loading...'); + + try { + const url = `https://ipinfo.io/${ip}/json?token=${apiKey}`; + const response = await fetch(url); + if (!response.ok) { + let errorDetail = ''; + try { + const errorJson = await response.json(); + errorDetail = errorJson.error?.message || JSON.stringify(errorJson.error) || response.statusText; + } catch (e) { + errorDetail = await response.text(); + } + throw new Error(`API Error (${response.status}): ${errorDetail.substring(0, 150)}`); + } + const json = await response.json(); + + if (json.bogon) { + updateStatus('Private/Reserved IP detected.', 'warn'); + updateLocationDisplay('N/A (Bogon IP)'); + currentIP = ip; + updateIpDisplay(ip) + if (fetchBtn && ip && apiKey) fetchBtn.disabled = false; + return; + } + + if (json.ip) { + currentIP = json.ip; + updateIpDisplay(currentIP) + + let details = []; + if (json.city) details.push(`City: ${json.city}`); + if (json.region) details.push(`Region: ${json.region}`); + if (json.country) details.push(`Country: ${json.country}`); + if (json.org) details.push(`Org: ${json.org}`); + if (json.postal) details.push(`Postal: ${json.postal}`); + if (json.timezone) details.push(`Timezone: ${json.timezone}`); + + updateLocationDisplay(details.length > 0 ? details.join('
') : 'Details unavailable'); + + if (json.loc) { + mapLink = `https://www.google.com/maps?q=${json.loc}`; + if (mapBtn) { + mapBtn.disabled = false; + } + } else { + if (mapBtn) mapBtn.disabled = true; + } + updateStatus('Details loaded successfully.', 'success'); + } else { + throw new Error("Invalid data received from API."); + } + + } catch (error) { + console.error("Failed to get IP information:", error); + updateStatus(`Error: ${error.message}`, 'error'); + updateLocationDisplay('Failed to load'); + if (mapBtn) mapBtn.disabled = true; + } finally { + if (fetchBtn && currentIP && apiKey) { + fetchBtn.disabled = false; + } else if (fetchBtn) { + fetchBtn.disabled = true; + } + } + } + + // --- WebRTC Hooking --- + // Overrides the browser's default RTCPeerConnection functions (`addIceCandidate`, `setRemoteDescription`) + // to intercept WebRTC negotiation details. It specifically looks for 'srflx' candidates, which + // usually contain the peer's public IP address. When found, it updates the UI and potentially + // triggers an automatic detail fetch if configured. Includes checks to prevent re-hooking. + function setupWebRTCHook() { + if (!window.RTCPeerConnection) { + console.warn("RTCPeerConnection not found. Cannot intercept IP."); + updateStatus("WebRTC not supported by browser.", "error"); + return; + } + + if (window.oRTCPeerConnection) { + console.log("WebRTC hook already active."); + if (document.getElementById('ipInfoStatus')) { + const currentStatus = document.getElementById('ipInfoStatus').textContent; + if (currentStatus.includes('Waiting') || currentStatus.includes('Ready') || currentStatus.includes('captured') || currentStatus.includes('Hook active')) { // Added 'Hook active' check + updateStatus("WebRTC hook already active.", "info"); + } + } + return; + } + + window.oRTCPeerConnection = window.RTCPeerConnection; + + window.RTCPeerConnection = function(...args) { + console.log("RTCPeerConnection created (hooked)"); + const pc = new window.oRTCPeerConnection(...args); + const originalAddIceCandidate = pc.addIceCandidate; + const originalSetRemoteDescription = pc.setRemoteDescription; + + pc.addIceCandidate = function(iceCandidate, ...rest) { + if (iceCandidate && iceCandidate.candidate) { + const candidateString = iceCandidate.candidate; + const ipMatch = candidateString.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/); + const typeMatch = candidateString.match(/typ\s+(srflx|prflx|relay|host)/); + + if (ipMatch && typeMatch && typeMatch[1] === "srflx") { + const ip = ipMatch[0]; + const type = typeMatch[1]; + + console.log(`Captured ${type} IP: ${ip} from addIceCandidate`); + + if (currentIP !== ip) { + currentIP = ip; + updateIpDisplay(ip); + updateLocationDisplay('-'); + mapLink = null; + const fetchBtn = document.getElementById('fetchDetailsBtn'); + const mapBtn = document.getElementById('openMapBtn'); + + if (fetchBtn) fetchBtn.disabled = !apiKey; + if (mapBtn) mapBtn.disabled = true; + + if (autoFetch && apiKey) { + console.log("Auto-fetching details for:", ip); + gather(ip); + } else if (autoFetch && !apiKey) { + updateStatus('IP captured. Auto-fetch needs API Key.', 'warn'); + } else { + updateStatus('IP captured. Click "Fetch Details".', 'info'); + } + } + } + } + return originalAddIceCandidate.apply(this, [iceCandidate, ...rest]); + }; + + pc.setRemoteDescription = function(sdp, ...rest) { + if (sdp && sdp.sdp) { + const sdpLines = sdp.sdp.split('\r\n'); + for (const line of sdpLines) { + if (line.startsWith('a=candidate:') && line.includes(' typ srflx ')) { + const fields = line.split(' '); + if (fields.length >= 8) { + const ip = fields[4]; + const type = fields[7]; + console.log(`Captured ${type} IP: ${ip} from setRemoteDescription`); + + if (currentIP !== ip) { + currentIP = ip; + updateIpDisplay(ip); + updateLocationDisplay('-'); + mapLink = null; + const fetchBtn = document.getElementById('fetchDetailsBtn'); + const mapBtn = document.getElementById('openMapBtn'); + + if (fetchBtn) fetchBtn.disabled = !apiKey; + if (mapBtn) mapBtn.disabled = true; + + if (autoFetch && apiKey) { + console.log("Auto-fetching details for:", ip); + gather(ip); + } else if (autoFetch && !apiKey) { + updateStatus('IP captured. Auto-fetch needs API Key.', 'warn'); + } else { + updateStatus('IP captured. Click "Fetch Details".', 'info'); + } + } + // break; // Optional: stop after first srflx in SDP + } + } + } + } + return originalSetRemoteDescription.apply(this, [sdp, ...rest]); + }; + + return pc; + }; + console.log("RTCPeerConnection hook installed successfully."); + updateStatus(apiKey ? 'Hook active. Waiting for connection...' : 'Hook active. API Key missing!', apiKey ? 'info' : 'warn'); + } + + + // --- Initialization --- + // Sets up the script when the page loads. It ensures the DOM is ready, + // then creates the UI elements and attempts to set up the WebRTC hook automatically. + // Includes a check to prevent creating duplicate UI if the script runs multiple times. + function initialize() { + if (!document.getElementById('ipInfoOverlay')) { + createUI(); + setupWebRTCHook(); + } else { + console.log("IP Info Tool UI already exists. Skipping creation."); + setupWebRTCHook(); // ensure the hook is always attempted on run + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + // DOM is already ready + initialize(); + } + +})(); \ No newline at end of file diff --git a/static/ui.png b/static/ui.png new file mode 100644 index 0000000000000000000000000000000000000000..e28d11a85410fc4ab01b324871417c0c2166bb50 GIT binary patch literal 11171 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 literal 0 HcmV?d00001