├── .env ├── .github ├── ha-stats.png └── workflows │ ├── tor.yml │ └── haconfig.yml ├── dashboard ├── postcss.config.js ├── tsconfig.json ├── src │ ├── main.tsx │ ├── lib │ │ ├── utils.ts │ │ └── api.ts │ ├── components │ │ ├── ui │ │ │ ├── badge.tsx │ │ │ ├── card.tsx │ │ │ ├── button.tsx │ │ │ ├── tabs.tsx │ │ │ └── toast.tsx │ │ ├── Diagnostics.tsx │ │ ├── CircuitMonitor.tsx │ │ ├── SystemOverview.tsx │ │ └── NetworkTraffic.tsx │ ├── types │ │ └── index.ts │ ├── index.css │ └── App.tsx ├── .gitignore ├── vite.config.ts ├── index.html ├── dockerfile ├── eslint.config.js ├── tsconfig.node.json ├── tsconfig.app.json ├── public │ └── vite.svg ├── package.json ├── tailwind.config.js └── README.md ├── metrics ├── requirements.txt ├── dockerfile └── app │ └── main.py ├── env.example ├── haproxy ├── dockerfile └── check-tor.sh ├── haconfig ├── dockerfile ├── haproxy.j2 └── gen_conf.py ├── get-relays.sh ├── tor ├── dockerfile └── torrc ├── .gitignore ├── get-exits.py ├── speedtest.sh ├── loadtest.py ├── docker-compose.yml └── README.md /.env: -------------------------------------------------------------------------------- 1 | SOCKS=5 -------------------------------------------------------------------------------- /.github/ha-stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshhighet/multisocks/HEAD/.github/ha-stats.png -------------------------------------------------------------------------------- /dashboard/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } -------------------------------------------------------------------------------- /metrics/requirements.txt: -------------------------------------------------------------------------------- 1 | stem 2 | geoip2 3 | fastapi>=0.115.0 4 | uvicorn[standard]>=0.30.0 5 | docker 6 | requests[socks] -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # multisocks 2 | # copy this file to .env and customize 3 | 4 | # number of Tor instances 5 | SOCKS=5 6 | 7 | # CORS 8 | HOSTNAME=localhost 9 | -------------------------------------------------------------------------------- /dashboard/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /haproxy/dockerfile: -------------------------------------------------------------------------------- 1 | FROM haproxy:alpine 2 | LABEL org.opencontainers.image.source https://github.com/joshhighet/multisocks/haproxy 3 | USER root 4 | RUN apk add --no-cache curl 5 | USER haproxy -------------------------------------------------------------------------------- /haconfig/dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-alpine 2 | LABEL org.opencontainers.image.source https://github.com/joshhighet/multisocks/haconfig 3 | WORKDIR /usr/app/ 4 | RUN pip install docker jinja2 5 | COPY haproxy.j2 /usr/app/haproxy.j2 6 | COPY gen_conf.py /usr/app/gen_conf.py -------------------------------------------------------------------------------- /get-relays.sh: -------------------------------------------------------------------------------- 1 | for container in $(docker ps --filter "ancestor=multisocks-private-tor" --format "{{.ID}}"); do 2 | docker exec -u root $container \ 3 | curl -s --socks5-hostname localhost:9050 \ 4 | https://cloudflare.com/cdn-cgi/trace \ 5 | | grep ip | sed 's/ip=//' 6 | done -------------------------------------------------------------------------------- /dashboard/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) -------------------------------------------------------------------------------- /tor/dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | LABEL org.opencontainers.image.source https://github.com/joshhighet/multisocks/tor 3 | RUN apk update 4 | RUN apk upgrade 5 | RUN apk add --no-cache tor curl 6 | COPY torrc /etc/tor/torrc 7 | RUN chown -R tor /etc/tor 8 | USER tor 9 | ENTRYPOINT ["tor"] 10 | CMD ["-f", "/etc/tor/torrc"] -------------------------------------------------------------------------------- /dashboard/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /dashboard/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | '@': path.resolve(__dirname, './src'), 11 | }, 12 | }, 13 | server: { 14 | port: 3000, 15 | host: true, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /dashboard/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | multisocks Dashboard 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /tor/torrc: -------------------------------------------------------------------------------- 1 | SocksPort 0.0.0.0:9050 2 | DataDirectory /var/lib/tor 3 | 4 | ## tracing 5 | Log notice syslog 6 | 7 | ## traffic mangling 8 | # SocksPolicy accept 172.17.0.0/16 9 | # SocksPolicy reject * 10 | 11 | ## allow nyx debugging or healthchecks 12 | ControlPort 0.0.0.0:9051 13 | # CookieAuthentication 1 14 | 15 | # tor --hash-password "log4j2.enableJndiLookup" 16 | HashedControlPassword 16:FEDC93554309AE5F608FB157AD5B85303A690B1A90D162EDCCEB929305 17 | -------------------------------------------------------------------------------- /metrics/dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-alpine 2 | LABEL org.opencontainers.image.source https://github.com/joshhighet/multisocks/metrics 3 | 4 | RUN apk update && apk upgrade && \ 5 | apk add --no-cache \ 6 | python3 \ 7 | py3-pip \ 8 | build-base \ 9 | docker-cli 10 | 11 | WORKDIR /app 12 | COPY requirements.txt . 13 | RUN pip install --no-cache-dir -r requirements.txt 14 | COPY app /app 15 | COPY GeoLite2-City.mmdb /app 16 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] -------------------------------------------------------------------------------- /dashboard/dockerfile: -------------------------------------------------------------------------------- 1 | # build 2 | FROM node:20-alpine AS builder 3 | LABEL org.opencontainers.image.source https://github.com/joshhighet/multisocks/metrics 4 | WORKDIR /app 5 | COPY package*.json ./ 6 | RUN npm ci 7 | COPY . . 8 | RUN npm run build 9 | 10 | # host 11 | FROM node:20-alpine AS production 12 | WORKDIR /app 13 | RUN npm install -g serve 14 | COPY --from=builder /app/dist ./dist 15 | RUN addgroup -g 1001 -S nodejs && \ 16 | adduser -S nextjs -u 1001 17 | RUN chown -R nextjs:nodejs /app 18 | USER nextjs 19 | EXPOSE 3000 20 | CMD ["serve", "-s", "dist", "-l", "3000"] -------------------------------------------------------------------------------- /dashboard/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { defineConfig, globalIgnores } from 'eslint/config' 7 | 8 | export default defineConfig([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /dashboard/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export function formatBytes(bytes: number): string { 9 | if (bytes === 0) return '0 B' 10 | 11 | const k = 1024 12 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] 13 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 14 | 15 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` 16 | } 17 | 18 | export function formatLatency(ms: number): string { 19 | if (ms < 1000) return `${Math.round(ms)}ms` 20 | if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` 21 | return `${(ms / 60000).toFixed(1)}m` 22 | } 23 | -------------------------------------------------------------------------------- /dashboard/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/tor.yml: -------------------------------------------------------------------------------- 1 | name: tor/ 2 | on: 3 | push: 4 | paths: 5 | - 'tor/**' 6 | branches: [ main ] 7 | workflow_dispatch: 8 | jobs: 9 | push-to-github-cr: 10 | name: push image to github container registry 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout the repo 14 | uses: actions/checkout@v2 15 | - name: authenticate to gh container registry 16 | uses: docker/login-action@v1.10.0 17 | with: 18 | registry: ghcr.io 19 | username: ${{ github.repository_owner }} 20 | password: ${{ secrets.GITHUB_TOKEN }} 21 | - name: build & push image 22 | uses: docker/build-push-action@v2.6.1 23 | with: 24 | context: ./tor 25 | push: true 26 | tags: ghcr.io/${{ github.repository }}/tor:latest 27 | -------------------------------------------------------------------------------- /.github/workflows/haconfig.yml: -------------------------------------------------------------------------------- 1 | name: haconfig/ 2 | on: 3 | push: 4 | paths: 5 | - 'haconfig/**' 6 | branches: [ main ] 7 | workflow_dispatch: 8 | jobs: 9 | push-to-github-cr: 10 | name: push image to github container registry 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout the repo 14 | uses: actions/checkout@v2 15 | - name: authenticate to gh container registry 16 | uses: docker/login-action@v1.10.0 17 | with: 18 | registry: ghcr.io 19 | username: ${{ github.repository_owner }} 20 | password: ${{ secrets.GITHUB_TOKEN }} 21 | - name: build & push image 22 | uses: docker/build-push-action@v2.6.1 23 | with: 24 | context: ./haconfig 25 | push: true 26 | tags: ghcr.io/${{ github.repository }}/haconfig:latest 27 | -------------------------------------------------------------------------------- /dashboard/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "types": ["vite/client"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /haconfig/haproxy.j2: -------------------------------------------------------------------------------- 1 | global 2 | external-check 3 | insecure-fork-wanted 4 | log stdout format raw local0 info 5 | 6 | defaults 7 | timeout client 60s 8 | timeout connect 5s 9 | timeout server 60s 10 | timeout http-request 60s 11 | log global 12 | 13 | frontend loadbalancer 14 | mode tcp 15 | bind :8080 16 | use_backend tors 17 | 18 | listen stats 19 | bind :1337 20 | mode http 21 | stats enable 22 | stats hide-version 23 | stats realm Haproxy\ Statistics 24 | stats uri / 25 | stats admin if LOCALHOST 26 | 27 | backend tors 28 | balance leastconn 29 | option external-check 30 | external-check path "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 31 | external-check command "/usr/local/etc/haproxy/check-tor.sh" 32 | {%- for host in tor_hosts %} 33 | server tor{{loop.index}} {{host}}:9050 check fall 5 rise 2 inter 7s 34 | {%- endfor %} 35 | 36 | 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | __pycache__/ 3 | *.pyc 4 | *.mmdb 5 | *.pyo 6 | *.pyd 7 | .Python 8 | env/ 9 | venv/ 10 | .venv/ 11 | dist/ 12 | build/ 13 | *.egg-info/ 14 | .vscode/ 15 | .idea/ 16 | *.swp 17 | *.swo 18 | *~ 19 | .DS_Store 20 | .DS_Store? 21 | ._* 22 | .Spotlight-V100 23 | .Trashes 24 | ehthumbs.db 25 | Thumbs.db 26 | *.log 27 | logs/ 28 | pids/ 29 | *.pid 30 | *.seed 31 | *.pid.lock 32 | coverage/ 33 | *.lcov 34 | .nyc_output 35 | jspm_packages/ 36 | .npm 37 | .eslintcache 38 | .rpt2_cache/ 39 | .rts2_cache_cjs/ 40 | .rts2_cache_es/ 41 | .rts2_cache_umd/ 42 | .node_repl_history 43 | *.tgz 44 | .yarn-integrity 45 | .env 46 | .env.local 47 | .env.development.local 48 | .env.test.local 49 | .env.production.local 50 | !env.example 51 | .cache 52 | .parcel-cache 53 | .next 54 | .nuxt 55 | .cache/ 56 | public 57 | .out 58 | .storybook-out 59 | tmp/ 60 | temp/ 61 | .dockerignore 62 | .vite/ 63 | *.tsbuildinfo 64 | .env.local -------------------------------------------------------------------------------- /get-exits.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import re 3 | 4 | def get_container_ips(): 5 | client = docker.from_env() 6 | containers = client.containers.list(filters={"ancestor": "multisocks-private-tor"}) 7 | 8 | ips = [] 9 | for container in containers: 10 | exec_log = container.exec_run( 11 | "curl -s --socks5-hostname localhost:9050 https://cloudflare.com/cdn-cgi/trace", 12 | user="root" 13 | ) 14 | result = exec_log.output.decode('utf-8') 15 | ip_match = re.search(r'ip=([^\n]+)', result) 16 | if ip_match: 17 | ips.append((container.id[:12], ip_match.group(1))) 18 | 19 | return ips 20 | 21 | if __name__ == "__main__": 22 | container_ips = get_container_ips() 23 | if container_ips: 24 | for container_id, ip in container_ips: 25 | print(f"Container ID: {container_id}, IP: {ip}") 26 | else: 27 | print("No IPs found.") -------------------------------------------------------------------------------- /speedtest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | URL="https://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion/" 4 | COUNT=50 5 | MULTISOCKS_PROXY="multisocks.dark:8080" 6 | LOCAL_TOR_PROXY="localhost:9050" 7 | 8 | function make_concurrent_requests() { 9 | local proxy_address=$1 10 | echo "making $COUNT concurrent requests to $URL via SOCKS5 proxy at $proxy_address" 11 | local start_time=$(date +%s.%N) 12 | for i in $(seq 1 $COUNT); do 13 | curl -s -x socks5h://$proxy_address --connect-timeout 10 --retry 5 $URL > /dev/null & 14 | done 15 | wait 16 | local end_time=$(date +%s.%N) 17 | local elapsed=$(echo "$end_time - $start_time" | bc) 18 | echo "time taken for $COUNT concurrent requests through proxy ($proxy_address): $elapsed seconds" 19 | } 20 | 21 | function check_latency() { 22 | local proxy_address=$1 23 | local time_taken=$(curl -o /dev/null -s -w '%{time_total}\n' -x socks5://$proxy_address $URL) 24 | echo "latency for a request through $proxy_address: $time_taken seconds" 25 | } 26 | 27 | check_latency $MULTISOCKS_PROXY 28 | make_concurrent_requests $MULTISOCKS_PROXY 29 | 30 | check_latency $LOCAL_TOR_PROXY 31 | make_concurrent_requests $LOCAL_TOR_PROXY 32 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | const badgeVariants = cva( 5 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 6 | { 7 | variants: { 8 | variant: { 9 | default: 10 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 11 | secondary: 12 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 13 | destructive: 14 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 15 | outline: "text-foreground", 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: "default", 20 | }, 21 | } 22 | ) 23 | 24 | export interface BadgeProps 25 | extends React.HTMLAttributes, 26 | VariantProps {} 27 | 28 | function Badge({ className, variant, ...props }: BadgeProps) { 29 | return ( 30 |
31 | ) 32 | } 33 | 34 | export { Badge, badgeVariants } 35 | -------------------------------------------------------------------------------- /haconfig/gen_conf.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import platform 3 | from jinja2 import Template 4 | 5 | def get_dockernet_hostnames(): 6 | if platform.system() == "Darwin": 7 | client = docker.DockerClient(base_url='unix://var/run/docker.sock') 8 | else: 9 | client = docker.DockerClient(base_url='unix://tmp/docker.sock') 10 | network = client.networks.get("net_tor") 11 | net_tor_id = network.attrs["Id"] 12 | containers = client.containers.list() 13 | container_names = [ 14 | container.attrs['Name'][1:] 15 | for container in containers 16 | if ("net_tor" in container.attrs["NetworkSettings"]["Networks"]) 17 | and (container.attrs["NetworkSettings"]["Networks"]["net_tor"]["NetworkID"] == net_tor_id) 18 | and (container.attrs["Config"]["User"] == "tor") 19 | ] 20 | sorted_container_names = sorted(container_names, key=lambda x: int(x.split('-')[-1])) 21 | return sorted_container_names 22 | 23 | if __name__ == "__main__": 24 | cihosts = get_dockernet_hostnames() 25 | with open("haproxy.j2", "r") as file: 26 | conf = Template(file.read()).render(tor_hosts=cihosts) 27 | if platform.system() == "Darwin": 28 | outfile = "haproxy-valid.cfg" 29 | else: 30 | outfile = '/usr/local/etc/haproxy/haproxy.cfg' 31 | with open(outfile, "w") as file: 32 | file.write(conf) 33 | -------------------------------------------------------------------------------- /dashboard/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dashboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dashboard", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-accordion": "^1.2.12", 14 | "@radix-ui/react-dialog": "^1.1.15", 15 | "@radix-ui/react-tabs": "^1.1.13", 16 | "@radix-ui/react-tooltip": "^1.2.8", 17 | "@tanstack/react-query": "^5.90.2", 18 | "@tanstack/react-table": "^8.21.3", 19 | "class-variance-authority": "^0.7.1", 20 | "clsx": "^2.1.1", 21 | "lucide-react": "^0.545.0", 22 | "react": "^19.1.1", 23 | "react-dom": "^19.1.1", 24 | "recharts": "^3.2.1", 25 | "tailwind-merge": "^3.3.1", 26 | "tailwindcss-animate": "^1.0.7" 27 | }, 28 | "devDependencies": { 29 | "@eslint/js": "^9.36.0", 30 | "@tailwindcss/postcss": "^4.1.14", 31 | "@types/node": "^24.7.0", 32 | "@types/react": "^19.1.16", 33 | "@types/react-dom": "^19.1.9", 34 | "@vitejs/plugin-react": "^5.0.4", 35 | "autoprefixer": "^10.4.21", 36 | "eslint": "^9.36.0", 37 | "eslint-plugin-react-hooks": "^5.2.0", 38 | "eslint-plugin-react-refresh": "^0.4.22", 39 | "globals": "^16.4.0", 40 | "postcss": "^8.5.6", 41 | "tailwindcss": "^4.1.14", 42 | "typescript": "~5.9.3", 43 | "typescript-eslint": "^8.45.0", 44 | "vite": "^7.1.11" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "../../lib/utils" 3 | 4 | const Card = React.forwardRef< 5 | HTMLDivElement, 6 | React.HTMLAttributes 7 | >(({ className, ...props }, ref) => ( 8 |
16 | )) 17 | Card.displayName = "Card" 18 | 19 | const CardHeader = React.forwardRef< 20 | HTMLDivElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 |
28 | )) 29 | CardHeader.displayName = "CardHeader" 30 | 31 | const CardTitle = React.forwardRef< 32 | HTMLParagraphElement, 33 | React.HTMLAttributes 34 | >(({ className, ...props }, ref) => ( 35 |

43 | )) 44 | CardTitle.displayName = "CardTitle" 45 | 46 | const CardContent = React.forwardRef< 47 | HTMLDivElement, 48 | React.HTMLAttributes 49 | >(({ className, ...props }, ref) => ( 50 |
51 | )) 52 | CardContent.displayName = "CardContent" 53 | 54 | export { Card, CardHeader, CardTitle, CardContent } 55 | -------------------------------------------------------------------------------- /dashboard/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | border: "hsl(var(--border))", 11 | input: "hsl(var(--input))", 12 | ring: "hsl(var(--ring))", 13 | background: "hsl(var(--background))", 14 | foreground: "hsl(var(--foreground))", 15 | primary: { 16 | DEFAULT: "hsl(var(--primary))", 17 | foreground: "hsl(var(--primary-foreground))", 18 | }, 19 | secondary: { 20 | DEFAULT: "hsl(var(--secondary))", 21 | foreground: "hsl(var(--secondary-foreground))", 22 | }, 23 | destructive: { 24 | DEFAULT: "hsl(var(--destructive))", 25 | foreground: "hsl(var(--destructive-foreground))", 26 | }, 27 | muted: { 28 | DEFAULT: "hsl(var(--muted))", 29 | foreground: "hsl(var(--muted-foreground))", 30 | }, 31 | accent: { 32 | DEFAULT: "hsl(var(--accent))", 33 | foreground: "hsl(var(--accent-foreground))", 34 | }, 35 | popover: { 36 | DEFAULT: "hsl(var(--popover))", 37 | foreground: "hsl(var(--popover-foreground))", 38 | }, 39 | card: { 40 | DEFAULT: "hsl(var(--card))", 41 | foreground: "hsl(var(--card-foreground))", 42 | }, 43 | }, 44 | borderRadius: { 45 | lg: "var(--radius)", 46 | md: "calc(var(--radius) - 2px)", 47 | sm: "calc(var(--radius) - 4px)", 48 | }, 49 | }, 50 | }, 51 | plugins: [], 52 | } -------------------------------------------------------------------------------- /haproxy/check-tor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/ash 2 | # this checks if a tor circuit has been completed by polling the controlport 3 | # it is used by haproxy to as a health check for the various backends 4 | # the arguments passed to this byhaproxy are: 5 | # the password for the controlport is included in the script - the hashed value within torrc 6 | # https://www.haproxy.com/documentation/haproxy-configuration-manual/latest/#4.2-external-check%20command 7 | 8 | #HAPROXY_PROXY_ADDR: 9 | #HAPROXY_PROXY_ID: 3 10 | #HAPROXY_PROXY_NAME: tors 11 | #HAPROXY_PROXY_PORT: 12 | #HAPROXY_SERVER_ADDR: 172.19.0.9 13 | #HAPROXY_SERVER_CURCONN: 4 14 | #HAPROXY_SERVER_ID: 1 15 | #HAPROXY_SERVER_MAXCONN: 0 16 | #HAPROXY_SERVER_NAME: tor1 17 | #HAPROXY_SERVER_PORT: 9050 18 | #HAPROXY_SERVER_SSL: 0 19 | #HAPROXY_SERVER_PROTO: tcp 20 | 21 | if [ -z "${3}" ] 22 | then 23 | if [ -z "${1}" ] 24 | then 25 | hostaddr="localhost" 26 | else 27 | hostaddr=${1} 28 | fi 29 | else 30 | hostaddr=${3} 31 | fi 32 | 33 | if ! nc -z ${hostaddr} 9051 2>/dev/null 34 | then 35 | echo "healthcheck: controlport (${hostaddr}:9051) is not accepting connections" 36 | exit 1 37 | fi 38 | 39 | # i dont quite know *why* this works, but it acts as a keepalive for the controlport... 40 | # https://github.com/joshhighet/multisocks/issues/1 41 | curl --silent --max-time 2 --socks5-hostname ${hostaddr}:9050 -I multisocks-haproxy-1:1337 42 | 43 | telnet_out=$(echo -e "authenticate \"log4j2.enableJndiLookup\"\ngetinfo circuit-status\nquit" | nc ${hostaddr} 9051 ) 44 | echo "${telnet_out}" | grep -q 'BUILT' 45 | if [ $? -eq 0 ] 46 | then 47 | # echo "healthcheck: (${hostaddr}:9051) has built at-least one circuit" 48 | exit 0 49 | else 50 | echo "healthcheck: (${hostaddr}:9051) has not finished building any circuits" 51 | exit 1 52 | fi 53 | -------------------------------------------------------------------------------- /loadtest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import aiohttp 3 | import aiohttp_socks 4 | import time 5 | import requests 6 | import logging 7 | 8 | logging.basicConfig( 9 | format="%(asctime)s [%(levelname)s]: %(message)s", 10 | level=logging.INFO 11 | ) 12 | 13 | def fetch_rwonline(): 14 | try: 15 | response = requests.get("https://ransomwhat.telemetry.ltd/groups") 16 | response.raise_for_status() 17 | data = response.json() 18 | urls = [] 19 | for group in data: 20 | for location in group.get("locations", []): 21 | if location.get("available") == True: 22 | urls.append(location.get("slug")) 23 | print(f"found {len(urls)} online hosts from ransomwatch to fetch with") 24 | return urls 25 | except requests.RequestException as e: 26 | logging.error(f"An error occurred: {e}") 27 | return [] 28 | 29 | async def fetch_url(session, url, semaphore: asyncio.Semaphore): 30 | async with semaphore: 31 | start_time = time.time() 32 | try: 33 | async with session.get(url, ssl=False) as response: 34 | duration = time.time() - start_time 35 | logging.info(f"Fetched {url} with status {response.status}. Time taken: {duration:.2f} seconds") 36 | except Exception as e: 37 | logging.error(f"Failed to fetch {url}. Error: {e}") 38 | 39 | async def main(): 40 | urls = fetch_rwonline() 41 | semaphore = asyncio.Semaphore(200) 42 | proxy = "socks5://multisocks.dark:8080" 43 | connector = aiohttp_socks.ProxyConnector.from_url(proxy) 44 | timeout = aiohttp.ClientTimeout(total=30) 45 | async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session: 46 | tasks = [fetch_url(session, url, semaphore) for url in urls] 47 | await asyncio.gather(*tasks) 48 | 49 | if __name__ == '__main__': 50 | asyncio.run(main()) 51 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | tor: 3 | build: ./tor 4 | #image: ghcr.io/joshhighet/multisocks/tor:latest 5 | networks: 6 | - net_tor 7 | expose: 8 | - 9050 9 | - 9051 10 | deploy: 11 | replicas: ${SOCKS:-5} 12 | restart: always 13 | 14 | metrics: 15 | build: ./metrics 16 | networks: 17 | - net_tor 18 | ports: 19 | - "8000:8000" 20 | depends_on: 21 | - tor 22 | expose: 23 | - 8000 24 | restart: always 25 | volumes: 26 | - /var/run/docker.sock:/var/run/docker.sock 27 | environment: 28 | - ALLOWED_ORIGINS=${HOSTNAME:-localhost} 29 | healthcheck: 30 | test: ["CMD", "curl", "-f", "http://localhost:8000/health"] 31 | interval: 30s 32 | timeout: 10s 33 | retries: 3 34 | start_period: 40s 35 | deploy: 36 | replicas: 1 37 | resources: 38 | limits: 39 | memory: 512M 40 | reservations: 41 | memory: 256M 42 | 43 | haproxy: 44 | build: ./haproxy 45 | restart: always 46 | depends_on: 47 | tor: 48 | condition: service_started 49 | haconfig-generator: 50 | condition: service_completed_successfully 51 | ports: 52 | - 8080:8080 53 | - 1337:1337 54 | volumes: 55 | - haproxy_conf:/usr/local/etc/haproxy 56 | - ./haproxy/check-tor.sh:/usr/local/etc/haproxy/check-tor.sh 57 | networks: 58 | - net_tor 59 | 60 | haconfig-generator: 61 | build: ./haconfig 62 | command: python gen_conf.py 63 | depends_on: 64 | tor: 65 | condition: service_started 66 | volumes: 67 | - haproxy_conf:/usr/local/etc/haproxy 68 | - /var/run/docker.sock:/tmp/docker.sock 69 | networks: 70 | - net_tor 71 | 72 | dashboard: 73 | build: ./dashboard 74 | ports: 75 | - "3000:3000" 76 | depends_on: 77 | - metrics 78 | networks: 79 | - net_tor 80 | 81 | volumes: 82 | haproxy_conf: 83 | 84 | networks: 85 | net_tor: 86 | name: net_tor 87 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | import { cn } from "../../lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "text-primary underline-offset-4 hover:underline", 20 | }, 21 | size: { 22 | default: "h-10 px-4 py-2", 23 | sm: "h-9 rounded-md px-3", 24 | lg: "h-11 rounded-md px-8", 25 | icon: "h-10 w-10", 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: "default", 30 | size: "default", 31 | }, 32 | } 33 | ) 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | asChild?: boolean 39 | } 40 | 41 | const Button = React.forwardRef( 42 | ({ className, variant, size, asChild = false, ...props }, ref) => { 43 | const Comp = asChild ? Slot : "button" 44 | return ( 45 | 50 | ) 51 | } 52 | ) 53 | Button.displayName = "Button" 54 | 55 | export { Button, buttonVariants } 56 | -------------------------------------------------------------------------------- /dashboard/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface TorHost { 2 | id: string 3 | ip_address: string 4 | external_ip?: string 5 | hostname: string 6 | image: string 7 | state: string 8 | } 9 | 10 | export interface CircuitNode { 11 | fingerprint: string 12 | nickname: string 13 | address: string 14 | location: { 15 | country: string 16 | city: string 17 | latitude: number | null 18 | longitude: number | null 19 | } 20 | } 21 | 22 | export interface Circuit { 23 | circuit_id: string 24 | purpose: string 25 | path: CircuitNode[] 26 | } 27 | 28 | export interface TorHostWithCircuits extends TorHost { 29 | circuits: Circuit[] 30 | error?: string 31 | } 32 | 33 | export interface HAProxyStats { 34 | pxname: string 35 | svname: string 36 | status: string 37 | scur: number 38 | smax: number 39 | stot: number 40 | bin: number 41 | bout: number 42 | ereq: number 43 | econ: number 44 | eresp: number 45 | wretr: number 46 | wredis: number 47 | weight: number 48 | act: number 49 | bck: number 50 | chkfail: number 51 | chkdown: number 52 | downtime: number 53 | rate: number 54 | rate_max: number 55 | hrsp_2xx: number 56 | hrsp_3xx: number 57 | hrsp_4xx: number 58 | hrsp_5xx: number 59 | cli_abrt: number 60 | srv_abrt: number 61 | lastsess: number 62 | qtime: number 63 | ctime: number 64 | rtime: number 65 | ttime: number 66 | check_status: string 67 | check_code: number 68 | check_duration: number 69 | last_chk: string 70 | qtime_max: number 71 | ctime_max: number 72 | rtime_max: number 73 | ttime_max: number 74 | } 75 | 76 | export interface SystemSummary { 77 | totalCircuits: number 78 | activeCircuits: number 79 | totalSessions: number 80 | totalBytesIn: number 81 | totalBytesOut: number 82 | averageLatency: number 83 | healthyBackends: number 84 | totalBackends: number 85 | uptime: number 86 | } 87 | 88 | export interface DashboardData { 89 | torHosts: TorHostWithCircuits[] 90 | haproxyStats: HAProxyStats[] 91 | summary: SystemSummary 92 | lastUpdated: string 93 | } 94 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | import { cn } from "../../lib/utils" 4 | 5 | const Tabs = TabsPrimitive.Root 6 | 7 | const TabsList = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | )) 20 | TabsList.displayName = TabsPrimitive.List.displayName 21 | 22 | const TabsTrigger = React.forwardRef< 23 | React.ElementRef, 24 | React.ComponentPropsWithoutRef 25 | >(({ className, ...props }, ref) => ( 26 | 34 | )) 35 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 36 | 37 | const TabsContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, ...props }, ref) => ( 41 | 49 | )) 50 | TabsContent.displayName = TabsPrimitive.Content.displayName 51 | 52 | export { Tabs, TabsList, TabsTrigger, TabsContent } 53 | -------------------------------------------------------------------------------- /dashboard/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## React Compiler 11 | 12 | The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). 13 | 14 | ## Expanding the ESLint configuration 15 | 16 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: 17 | 18 | ```js 19 | export default defineConfig([ 20 | globalIgnores(['dist']), 21 | { 22 | files: ['**/*.{ts,tsx}'], 23 | extends: [ 24 | // Other configs... 25 | 26 | // Remove tseslint.configs.recommended and replace with this 27 | tseslint.configs.recommendedTypeChecked, 28 | // Alternatively, use this for stricter rules 29 | tseslint.configs.strictTypeChecked, 30 | // Optionally, add this for stylistic rules 31 | tseslint.configs.stylisticTypeChecked, 32 | 33 | // Other configs... 34 | ], 35 | languageOptions: { 36 | parserOptions: { 37 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 38 | tsconfigRootDir: import.meta.dirname, 39 | }, 40 | // other options... 41 | }, 42 | }, 43 | ]) 44 | ``` 45 | 46 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 47 | 48 | ```js 49 | // eslint.config.js 50 | import reactX from 'eslint-plugin-react-x' 51 | import reactDom from 'eslint-plugin-react-dom' 52 | 53 | export default defineConfig([ 54 | globalIgnores(['dist']), 55 | { 56 | files: ['**/*.{ts,tsx}'], 57 | extends: [ 58 | // Other configs... 59 | // Enable lint rules for React 60 | reactX.configs['recommended-typescript'], 61 | // Enable lint rules for React DOM 62 | reactDom.configs.recommended, 63 | ], 64 | languageOptions: { 65 | parserOptions: { 66 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 67 | tsconfigRootDir: import.meta.dirname, 68 | }, 69 | // other options... 70 | }, 71 | }, 72 | ]) 73 | ``` 74 | -------------------------------------------------------------------------------- /dashboard/src/lib/api.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TorHost, 3 | TorHostWithCircuits, 4 | DashboardData 5 | } from '../types' 6 | 7 | class ApiClient { 8 | private baseUrl: string 9 | 10 | constructor(baseUrl?: string) { 11 | if (baseUrl) { 12 | this.baseUrl = baseUrl 13 | } else { 14 | const currentHost = window.location.hostname 15 | const isHttps = window.location.protocol === 'https:' 16 | this.baseUrl = `${isHttps ? 'https' : 'http'}://${currentHost}:8000` 17 | } 18 | } 19 | 20 | private async request(endpoint: string, options: RequestInit = {}): Promise { 21 | const url = `${this.baseUrl}${endpoint}` 22 | const response = await fetch(url, { 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | ...options.headers, 26 | }, 27 | ...options, 28 | }) 29 | 30 | if (!response.ok) { 31 | const error = await response.text() 32 | throw new Error(`API request failed: ${response.status} ${error}`) 33 | } 34 | 35 | return response.json() 36 | } 37 | 38 | async getDashboardData(): Promise { 39 | return this.request('/dashboard-data') 40 | } 41 | 42 | async getTorHosts(): Promise { 43 | return this.request('/tor-hosts') 44 | } 45 | 46 | async getTorHostCircuits(hostId: string): Promise { 47 | return this.request(`/tor-hosts/${hostId}/circuits`) 48 | } 49 | 50 | 51 | // NEW MANAGEMENT METHODS 52 | 53 | async rebuildHostCircuits(hostId: string): Promise<{ success: boolean; message: string }> { 54 | return this.request<{ success: boolean; message: string }>(`/tor-hosts/${hostId}/rebuild-circuits`, { 55 | method: 'POST', 56 | }) 57 | } 58 | 59 | async closeCircuit(circuitId: string, hostId: string): Promise<{ success: boolean; message: string }> { 60 | return this.request<{ success: boolean; message: string }>(`/circuits/${circuitId}/close?host_id=${hostId}`, { 61 | method: 'POST', 62 | }) 63 | } 64 | 65 | async rebuildAllCircuits(): Promise<{ success: boolean; results: Array<{ host_id: string; hostname: string; result: any }> }> { 66 | return this.request<{ success: boolean; results: Array<{ host_id: string; hostname: string; result: any }> }>('/circuits/rebuild-all', { 67 | method: 'POST', 68 | }) 69 | } 70 | 71 | async requestNewIdentity(hostId: string): Promise<{ success: boolean; message: string }> { 72 | return this.request<{ success: boolean; message: string }>(`/tor-hosts/${hostId}/new-identity`, { 73 | method: 'POST', 74 | }) 75 | } 76 | 77 | // WebSocket connection for real-time updates 78 | connectWebSocket(onMessage: (data: any) => void): WebSocket { 79 | const wsUrl = this.baseUrl.replace('http://', 'ws://').replace('https://', 'wss://') 80 | const ws = new WebSocket(`${wsUrl}/ws`) 81 | 82 | ws.onmessage = (event) => { 83 | try { 84 | const data = JSON.parse(event.data) 85 | onMessage(data) 86 | } catch (error) { 87 | console.error('Error parsing WebSocket message:', error) 88 | } 89 | } 90 | 91 | ws.onerror = (error) => { 92 | console.error('WebSocket error:', error) 93 | } 94 | 95 | ws.onclose = () => { 96 | console.log('WebSocket connection closed') 97 | } 98 | 99 | return ws 100 | } 101 | } 102 | 103 | export const apiClient = new ApiClient() -------------------------------------------------------------------------------- /dashboard/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @theme { 4 | --color-background: 0 0% 100%; 5 | --color-foreground: 222.2 84% 4.9%; 6 | --color-card: 0 0% 100%; 7 | --color-card-foreground: 222.2 84% 4.9%; 8 | --color-popover: 0 0% 100%; 9 | --color-popover-foreground: 222.2 84% 4.9%; 10 | --color-primary: 221.2 83.2% 53.3%; 11 | --color-primary-foreground: 210 40% 98%; 12 | --color-secondary: 210 40% 96%; 13 | --color-secondary-foreground: 222.2 84% 4.9%; 14 | --color-muted: 210 40% 96%; 15 | --color-muted-foreground: 215.4 16.3% 46.9%; 16 | --color-accent: 210 40% 96%; 17 | --color-accent-foreground: 222.2 84% 4.9%; 18 | --color-destructive: 0 84.2% 60.2%; 19 | --color-destructive-foreground: 210 40% 98%; 20 | --color-border: 214.3 31.8% 91.4%; 21 | --color-input: 214.3 31.8% 91.4%; 22 | --color-ring: 221.2 83.2% 53.3%; 23 | --radius: 0.5rem; 24 | } 25 | 26 | .dark { 27 | --color-background: 222.2 84% 4.9%; 28 | --color-foreground: 210 40% 98%; 29 | --color-card: 222.2 84% 4.9%; 30 | --color-card-foreground: 210 40% 98%; 31 | --color-popover: 222.2 84% 4.9%; 32 | --color-popover-foreground: 210 40% 98%; 33 | --color-primary: 217.2 91.2% 59.8%; 34 | --color-primary-foreground: 222.2 84% 4.9%; 35 | --color-secondary: 217.2 32.6% 17.5%; 36 | --color-secondary-foreground: 210 40% 98%; 37 | --color-muted: 217.2 32.6% 17.5%; 38 | --color-muted-foreground: 215 20.2% 65.1%; 39 | --color-accent: 217.2 32.6% 17.5%; 40 | --color-accent-foreground: 210 40% 98%; 41 | --color-destructive: 0 62.8% 30.6%; 42 | --color-destructive-foreground: 210 40% 98%; 43 | --color-border: 217.2 32.6% 17.5%; 44 | --color-input: 217.2 32.6% 17.5%; 45 | --color-ring: 224.3 76.3% 94.1%; 46 | } 47 | 48 | * { 49 | border-color: hsl(var(--color-border)); 50 | } 51 | 52 | body { 53 | background-color: hsl(var(--color-background)); 54 | color: hsl(var(--color-foreground)); 55 | transition: background-color 0.3s ease, color 0.3s ease; 56 | } 57 | 58 | /* Smooth transitions for dark mode */ 59 | .card, .border, .bg-muted, .bg-muted/50 { 60 | transition: background-color 0.3s ease, border-color 0.3s ease; 61 | } 62 | 63 | /* Custom scrollbar for dark mode */ 64 | ::-webkit-scrollbar { 65 | width: 8px; 66 | height: 8px; 67 | } 68 | 69 | ::-webkit-scrollbar-track { 70 | background: hsl(var(--color-muted)); 71 | } 72 | 73 | ::-webkit-scrollbar-thumb { 74 | background: hsl(var(--color-muted-foreground)); 75 | border-radius: 4px; 76 | } 77 | 78 | ::-webkit-scrollbar-thumb:hover { 79 | background: hsl(var(--color-foreground)); 80 | } 81 | 82 | /* Animation for loading states */ 83 | @keyframes pulse { 84 | 0%, 100% { 85 | opacity: 1; 86 | } 87 | 50% { 88 | opacity: 0.5; 89 | } 90 | } 91 | 92 | .animate-pulse { 93 | animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 94 | } 95 | 96 | /* Hover effects */ 97 | .hover\:bg-muted\/50:hover { 98 | background-color: hsl(var(--color-muted) / 0.5); 99 | } 100 | 101 | /* Status colors for dark mode */ 102 | .text-green-600 { 103 | color: rgb(22 163 74); 104 | } 105 | 106 | .dark .text-green-600 { 107 | color: rgb(34 197 94); 108 | } 109 | 110 | .text-red-500 { 111 | color: rgb(239 68 68); 112 | } 113 | 114 | .dark .text-red-500 { 115 | color: rgb(248 113 113); 116 | } 117 | 118 | .text-yellow-500 { 119 | color: rgb(234 179 8); 120 | } 121 | 122 | .dark .text-yellow-500 { 123 | color: rgb(250 204 21); 124 | } 125 | 126 | .text-blue-600 { 127 | color: rgb(37 99 235); 128 | } 129 | 130 | .dark .text-blue-600 { 131 | color: rgb(59 130 246); 132 | } 133 | 134 | .text-purple-600 { 135 | color: rgb(147 51 234); 136 | } 137 | 138 | .dark .text-purple-600 { 139 | color: rgb(168 85 247); 140 | } 141 | 142 | .text-cyan-600 { 143 | color: rgb(8 145 178); 144 | } 145 | 146 | .dark .text-cyan-600 { 147 | color: rgb(34 211 238); 148 | } 149 | 150 | .text-orange-600 { 151 | color: rgb(234 88 12); 152 | } 153 | 154 | .dark .text-orange-600 { 155 | color: rgb(251 146 60); 156 | } 157 | 158 | .text-emerald-600 { 159 | color: rgb(5 150 105); 160 | } 161 | 162 | .dark .text-emerald-600 { 163 | color: rgb(16 185 129); 164 | } -------------------------------------------------------------------------------- /dashboard/src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "../../lib/utils" 3 | import { X, CheckCircle, AlertCircle, Info } from "lucide-react" 4 | 5 | interface ToastProps { 6 | id: string 7 | title?: string 8 | description?: string 9 | type?: 'success' | 'error' | 'info' | 'warning' 10 | onClose: (id: string) => void 11 | } 12 | 13 | export function Toast({ id, title, description, type = 'info', onClose }: ToastProps) { 14 | const [isVisible, setIsVisible] = React.useState(true) 15 | 16 | React.useEffect(() => { 17 | const timer = setTimeout(() => { 18 | setIsVisible(false) 19 | setTimeout(() => onClose(id), 300) // Allow fade out animation 20 | }, 5000) 21 | 22 | return () => clearTimeout(timer) 23 | }, [id, onClose]) 24 | 25 | const getIcon = () => { 26 | switch (type) { 27 | case 'success': 28 | return 29 | case 'error': 30 | return 31 | case 'warning': 32 | return 33 | default: 34 | return 35 | } 36 | } 37 | 38 | const getBackgroundColor = () => { 39 | switch (type) { 40 | case 'success': 41 | return 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800' 42 | case 'error': 43 | return 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800' 44 | case 'warning': 45 | return 'bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800' 46 | default: 47 | return 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800' 48 | } 49 | } 50 | 51 | if (!isVisible) return null 52 | 53 | return ( 54 |
61 |
62 | {getIcon()} 63 |
64 | {title && ( 65 |
66 | {title} 67 |
68 | )} 69 | {description && ( 70 |
71 | {description} 72 |
73 | )} 74 |
75 | 84 |
85 |
86 | ) 87 | } 88 | 89 | interface ToastContextType { 90 | addToast: (toast: Omit) => void 91 | } 92 | 93 | const ToastContext = React.createContext(undefined) 94 | 95 | export function ToastProvider({ children }: { children: React.ReactNode }) { 96 | const [toasts, setToasts] = React.useState([]) 97 | 98 | const addToast = React.useCallback((toast: Omit) => { 99 | const id = Math.random().toString(36).substr(2, 9) 100 | setToasts(prev => [...prev, { ...toast, id, onClose: removeToast }]) 101 | }, []) 102 | 103 | const removeToast = React.useCallback((id: string) => { 104 | setToasts(prev => prev.filter(toast => toast.id !== id)) 105 | }, []) 106 | 107 | return ( 108 | 109 | {children} 110 |
111 | {toasts.map(toast => ( 112 | 113 | ))} 114 |
115 |
116 | ) 117 | } 118 | 119 | export function useToast() { 120 | const context = React.useContext(ToastContext) 121 | if (context === undefined) { 122 | throw new Error('useToast must be used within a ToastProvider') 123 | } 124 | return context 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # multisocks 2 | 3 | multisocks is a tool for running frameworks such as spiders or scanners against infrastructure (onion services) on the tor network 4 | 5 | it is a tcp load balanced SOCKS5 proxy that can speed up other tools by spawning up to 4095 tor circuits of which inbound requests can be distributed across 6 | 7 | it can significantly cut-down load times for correctly scaled applications by doing the following 8 | 9 | - creating a very large number of tor circuits 10 | - surfacing a single-ingress SOCKS5 proxy 11 | - adequately load-balance backend connections 12 | - performing health-checks against each backend tor circuit 13 | - serving a load balancer monitoring dashboard 14 | 15 | _multisocks is a fork/derivative of the excellent [Iglesys347/castor](https://github.com/Iglesys347/castor)_ 16 | 17 | multisocks exposes a SOCKS5 proxy on `:8080` and a statistics report on `:1337` by default. 18 | 19 | ```mermaid 20 | flowchart TB 21 | sclient[proxy client :8080]<-->front 22 | stat{{stats :1337}}-..->front 23 | front[haproxy] 24 | style stat stroke-width:2px,color:#fff,stroke-dasharray: 5 5 25 | style sclient stroke:#f66,stroke-width:2px,color:#fff,stroke-dasharray: 5 5 26 | subgraph scaler[x scaled instances] 27 | subgraph tor1 [tor] 28 | ctrl[controlport] 29 | sock[socksport] 30 | end 31 | subgraph tor2 [tor] 32 | ctrl2[controlport] 33 | sock2[socksport] 34 | end 35 | subgraph tor3 [tor] 36 | ctrl3[controlport] 37 | sock3[socksport] 38 | end 39 | end 40 | front<-->sock 41 | front-. healthcheck .-> ctrl 42 | front<-->sock2 43 | front-. healthcheck .-> ctrl2 44 | front<-->sock3 45 | front-. healthcheck .-> ctrl3 46 | ``` 47 | 48 | --- 49 | 50 | ## configuration 51 | 52 | if you do not define a number of Tor instances (ref `backends`) - it will default to 5. on 2x2 (`cpu`/`memory`) machine this can comfortably run 50 circuits. 53 | 54 | avoid defining more than `4095` backends - this is a haproxy limitation. to work around this, create a secondary backend group - do so referencing `backend tors` within [haproxy.j2](haconfig/haproxy.j2) and update the configuration template [haproxy.j2](haconfig/haproxy.j2) accordingly. 55 | 56 | a healthcheck is performed against each backend relay using haproxy's [external-check command](https://www.haproxy.com/documentation/haproxy-configuration-manual/latest/#4.2-external-check%20command) 57 | 58 | set the number of tor instances to be created by altering `SOCKS` within `.env` 59 | 60 | _reference `services.tor.deploy.replicas` within `docker-compose.yml`_ 61 | ## runtime 62 | 63 | ```shell 64 | git clone https://github.com/joshhighet/multisocks 65 | # add --detach at the end of the below command detatch the command from your existing session 66 | docker compose --file multisocks/docker-compose.yml up 67 | ``` 68 | 69 | ## stats & obserability 70 | 71 | to view the status of haproxy, navigate to `http://localhost:1337` in a browser. you should see the number of backends as defined in your environment along with other useful metrics 72 | 73 | ![haproxy stats, example](.github/ha-stats.png) 74 | 75 | to fetch state of each circuit you could leverage something similar to the below 76 | 77 | ```shell 78 | curl -s 'http://localhost:1337/;csv' \ 79 | | sed 's/,/ ,/g' | column -t -s, | less -S 80 | ``` 81 | 82 | ## debugging 83 | 84 | to trail logs, leverage `docker compose logs` 85 | 86 | ```shell 87 | cd multisocks 88 | docker compose logs --timestamps --follow 89 | ``` 90 | 91 | to enter a shell in a running container, use `docker exec`. 92 | 93 | to view your container names use `docker ps ` - replace `multisocks-haproxy` accordingly 94 | 95 | ```shell 96 | docker exec -it -u root multisocks-haproxy ash 97 | ``` 98 | 99 | ## testing 100 | 101 | this is a short script to check multisocks is running correctly. 102 | 103 | > it assumes multisocks is running locally and makes ten requests to Cloudflare, returning the requested IP for each request 104 | 105 | ```shell 106 | for i in {1..10}; do 107 | curl -sLx socks5://localhost:8080 cloudflare.com/cdn-cgi/trace | grep ip\= 108 | done 109 | ``` 110 | 111 | to test against hsdir resolutions, simply replace the cloudflare URL with an onion service 112 | 113 | > to find some online onion services, go browse around or use the below for starters 114 | 115 | ```shell 116 | curl -sL ransomwhat.telemetry.ltd/groups \ 117 | | jq -r '.[].locations[] | select(.available==true) | .slug' \ 118 | | head -n 10 119 | ``` 120 | 121 | see [loadtest.py](loadtest.py) & [speedtest.sh](speedtest.sh) for more thorough examples 122 | 123 | ## deployment 124 | 125 | copy `env.example` to `.env` and set your hostname 126 | 127 | ```shell 128 | cp env.example .env 129 | # edit .env - set HOSTNAME to your domain 130 | ``` 131 | 132 | download GeoLite2 db for location data (optional) 133 | 134 | ```shell 135 | # create account at https://dev.maxmind.com/geoip/geoip2/geolite2/ 136 | # download GeoLite2-City.mmdb and place in metrics/ directory 137 | ``` 138 | 139 | start services 140 | 141 | ```shell 142 | docker compose up -d 143 | ``` 144 | 145 | ## notes 146 | 147 | the current health-check implementation leaves much room for improvement. it uses netcat to send an _authenticated_ telnet command `getinfo circuit-status`. an alternate could be to use stem, with something like the below 148 | 149 | ```python 150 | import stem.control 151 | def is_circuit_built(): 152 | with stem.control.Controller.from_port(port=9051) as controller: 153 | controller.authenticate() 154 | circs = controller.get_circuits() 155 | for circ in circs: 156 | if circ.status == 'BUILT': 157 | return True 158 | return False 159 | ``` 160 | 161 | to hot reload the haproxy configuration without having to re-establish tor circuits with a full rebuild or restart, you can run the below (replacing `multisocks-haproxy-1` if appropriate) 162 | 163 | ```shell 164 | docker exec \ 165 | multisocks-haproxy-1 haproxy \ 166 | -f /usr/local/etc/haproxy/haproxy.cfg \ 167 | -p /var/run/haproxy.pid \ 168 | -sf $(cat /var/run/haproxy.pid) 169 | ``` 170 | -------------------------------------------------------------------------------- /dashboard/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { useQuery } from '@tanstack/react-query' 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 4 | import { Card, CardContent, CardHeader, CardTitle } from './components/ui/card' 5 | import { Button } from './components/ui/button' 6 | import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs' 7 | import { ToastProvider } from './components/ui/toast' 8 | import { SystemOverview } from './components/SystemOverview' 9 | import { NetworkTraffic } from './components/NetworkTraffic' 10 | import { CircuitMonitor } from './components/CircuitMonitor' 11 | import { Diagnostics } from './components/Diagnostics' 12 | import { apiClient } from './lib/api' 13 | import type { DashboardData } from './types' 14 | import { RefreshCw, Activity, Globe, Shield, Moon, Sun, AlertTriangle } from 'lucide-react' 15 | 16 | const queryClient = new QueryClient({ 17 | defaultOptions: { 18 | queries: { 19 | refetchInterval: 2000, // More frequent updates for real-time feel 20 | staleTime: 1000, 21 | }, 22 | }, 23 | }) 24 | 25 | function Dashboard() { 26 | const [lastUpdated, setLastUpdated] = useState(new Date()) 27 | const [darkMode, setDarkMode] = useState(false) 28 | const [activeTab, setActiveTab] = useState('overview') 29 | 30 | const { data: dashboardData, isLoading, error, refetch } = useQuery({ 31 | queryKey: ['dashboard'], 32 | queryFn: async () => { 33 | const data = await apiClient.getDashboardData() 34 | setLastUpdated(new Date()) 35 | return data 36 | }, 37 | }) 38 | 39 | const handleRefresh = () => { 40 | refetch() 41 | } 42 | 43 | const toggleDarkMode = () => { 44 | setDarkMode(!darkMode) 45 | document.documentElement.classList.toggle('dark') 46 | } 47 | 48 | useEffect(() => { 49 | const savedTheme = localStorage.getItem('theme') 50 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches 51 | 52 | if (savedTheme === 'dark' || (!savedTheme && prefersDark)) { 53 | setDarkMode(true) 54 | document.documentElement.classList.add('dark') 55 | } 56 | }, []) 57 | 58 | useEffect(() => { 59 | localStorage.setItem('theme', darkMode ? 'dark' : 'light') 60 | }, [darkMode]) 61 | 62 | // Calculate system health status 63 | const getSystemHealth = () => { 64 | if (!dashboardData) return { status: 'unknown', message: 'No data' } 65 | 66 | const healthyHosts = dashboardData.torHosts.filter(h => !h.error && h.circuits.length > 0).length 67 | const totalHosts = dashboardData.torHosts.length 68 | const activeCircuits = dashboardData.summary.activeCircuits 69 | 70 | if (healthyHosts === 0) return { status: 'critical', message: 'All hosts offline' } 71 | if (healthyHosts < totalHosts / 2) return { status: 'warning', message: `${healthyHosts}/${totalHosts} hosts healthy` } 72 | if (activeCircuits === 0) return { status: 'warning', message: 'No active circuits' } 73 | return { status: 'healthy', message: `${healthyHosts}/${totalHosts} hosts, ${activeCircuits} circuits` } 74 | } 75 | 76 | const systemHealth = getSystemHealth() 77 | 78 | if (isLoading) { 79 | return ( 80 |
81 |
82 | 83 |

Loading multisocks dashboard...

84 |

Connecting to Tor circuits

85 |
86 |
87 | ) 88 | } 89 | 90 | if (error) { 91 | return ( 92 |
93 | 94 | 95 | 96 | 97 | Connection Error 98 | 99 | 100 | 101 |

102 | Failed to connect to the multisocks metrics service. 103 |

104 |

105 | Make sure the metrics service is running on port 8000. 106 |

107 | 111 |
112 |
113 |
114 | ) 115 | } 116 | 117 | if (!dashboardData) { 118 | return ( 119 |
120 |
121 | 122 |

No Data Available

123 |

No dashboard data received

124 |
125 |
126 | ) 127 | } 128 | 129 | return ( 130 |
131 |
132 | {/* Header with System Status */} 133 |
134 |
135 |

136 | 137 | multisocks Dashboard 138 |

139 |
140 |
147 |
151 | {systemHealth.message} 152 |
153 |
154 | Last updated: {lastUpdated.toLocaleTimeString()} 155 |
156 |
157 |
158 |
159 | 162 | 166 |
167 |
168 | 169 | {/* Main Content Tabs */} 170 | 171 | 172 | System Overview 173 | Network Traffic 174 | Circuit Monitor 175 | Diagnostics 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 |
195 |
196 | ) 197 | } 198 | 199 | function App() { 200 | return ( 201 | 202 | 203 | 204 | 205 | 206 | ) 207 | } 208 | 209 | export default App -------------------------------------------------------------------------------- /dashboard/src/components/Diagnostics.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from './ui/card' 2 | import { Badge } from './ui/badge' 3 | import { Button } from './ui/button' 4 | import type { DashboardData } from '../types' 5 | import { formatBytes, formatLatency } from '../lib/utils' 6 | import { 7 | AlertTriangle, 8 | CheckCircle, 9 | XCircle, 10 | Clock, 11 | Activity, 12 | Server, 13 | Globe, 14 | Zap, 15 | RefreshCw, 16 | Download, 17 | Terminal 18 | } from 'lucide-react' 19 | 20 | interface DiagnosticsProps { 21 | data: DashboardData 22 | } 23 | 24 | export function Diagnostics({ data }: DiagnosticsProps) { 25 | const { summary, torHosts, haproxyStats } = data 26 | 27 | // Diagnostic checks 28 | const diagnostics = [ 29 | { 30 | name: 'SOCKS5 Proxy', 31 | status: 'healthy', 32 | message: 'Port 8080 accessible', 33 | details: 'SOCKS5 proxy is running and accepting connections' 34 | }, 35 | { 36 | name: 'HAProxy Load Balancer', 37 | status: summary.healthyBackends > 0 ? 'healthy' : 'warning', 38 | message: `${summary.healthyBackends}/${summary.totalBackends} backends healthy`, 39 | details: summary.healthyBackends > 0 40 | ? 'Load balancer is distributing traffic properly' 41 | : 'No healthy backends available' 42 | }, 43 | { 44 | name: 'Tor Hosts', 45 | status: torHosts.some(h => !h.error && h.circuits.length > 0) ? 'healthy' : 'critical', 46 | message: `${torHosts.filter(h => !h.error && h.circuits.length > 0).length}/${torHosts.length} hosts operational`, 47 | details: torHosts.some(h => !h.error && h.circuits.length > 0) 48 | ? 'At least one Tor host is operational' 49 | : 'All Tor hosts are offline or have no circuits' 50 | }, 51 | { 52 | name: 'Circuit Health', 53 | status: summary.activeCircuits > 0 ? 'healthy' : 'warning', 54 | message: `${summary.activeCircuits}/${summary.totalCircuits} circuits active`, 55 | details: summary.activeCircuits > 0 56 | ? 'Circuits are established and ready for traffic' 57 | : 'No active circuits available' 58 | }, 59 | { 60 | name: 'Performance', 61 | status: summary.averageLatency < 5000 ? 'healthy' : 'warning', 62 | message: `Average latency: ${formatLatency(summary.averageLatency)}`, 63 | details: summary.averageLatency < 5000 64 | ? 'Latency is within acceptable range' 65 | : 'High latency detected, may indicate network issues' 66 | } 67 | ] 68 | 69 | const getStatusIcon = (status: string) => { 70 | switch (status) { 71 | case 'healthy': return 72 | case 'warning': return 73 | case 'critical': return 74 | default: return 75 | } 76 | } 77 | 78 | const getStatusColor = (status: string) => { 79 | switch (status) { 80 | case 'healthy': return 'default' 81 | case 'warning': return 'secondary' 82 | case 'critical': return 'destructive' 83 | default: return 'outline' 84 | } 85 | } 86 | 87 | const exportDiagnostics = () => { 88 | const diagnosticReport = { 89 | timestamp: new Date().toISOString(), 90 | systemHealth: diagnostics, 91 | summary: summary, 92 | torHosts: torHosts.map(host => ({ 93 | hostname: host.hostname, 94 | ip: host.ip_address, 95 | status: host.error ? 'error' : host.circuits.length > 0 ? 'healthy' : 'no-circuits', 96 | circuits: host.circuits.length, 97 | error: host.error 98 | })), 99 | haproxyStats: haproxyStats 100 | } 101 | 102 | const blob = new Blob([JSON.stringify(diagnosticReport, null, 2)], { type: 'application/json' }) 103 | const url = URL.createObjectURL(blob) 104 | const a = document.createElement('a') 105 | a.href = url 106 | a.download = `multisocks-diagnostics-${new Date().toISOString().split('T')[0]}.json` 107 | document.body.appendChild(a) 108 | a.click() 109 | document.body.removeChild(a) 110 | URL.revokeObjectURL(url) 111 | } 112 | 113 | return ( 114 |
115 | {/* System Health Overview */} 116 | 117 | 118 | 119 | 120 | System Health Overview 121 | 122 | 123 | 124 |
125 | {diagnostics.map((diagnostic, index) => ( 126 |
127 | {getStatusIcon(diagnostic.status)} 128 |
129 |
130 |

{diagnostic.name}

131 | 132 | {diagnostic.status} 133 | 134 |
135 |

136 | {diagnostic.message} 137 |

138 |

139 | {diagnostic.details} 140 |

141 |
142 |
143 | ))} 144 |
145 |
146 |
147 | 148 | {/* Performance Metrics */} 149 | 150 | 151 | 152 | 153 | Performance Metrics 154 | 155 | 156 | 157 |
158 |
159 |
160 | {formatBytes(summary.totalBytesIn)} 161 |
162 |
Data In
163 |
164 |
165 |
166 | {formatBytes(summary.totalBytesOut)} 167 |
168 |
Data Out
169 |
170 |
171 |
172 | {summary.totalSessions.toLocaleString()} 173 |
174 |
Total Sessions
175 |
176 |
177 |
178 | {formatLatency(summary.averageLatency)} 179 |
180 |
Avg Latency
181 |
182 |
183 |
184 |
185 | 186 | {/* Host Diagnostics */} 187 | 188 | 189 | 190 | 191 | Host Diagnostics 192 | 193 | 194 | 195 |
196 | {torHosts.map((host) => ( 197 |
198 |
199 |
200 | {getStatusIcon( 201 | host.error ? 'critical' : 202 | host.circuits.length > 0 ? 'healthy' : 'warning' 203 | )} 204 | {host.hostname} 205 | 206 | {host.ip_address} 207 | 208 |
209 | 0 ? 'default' : 'secondary' 212 | }> 213 | {host.error ? 'Error' : 214 | host.circuits.length > 0 ? 'Healthy' : 'No Circuits'} 215 | 216 |
217 | 218 | {host.error ? ( 219 |
220 | Error: {host.error} 221 |
222 | ) : ( 223 |
224 |
225 |
Circuits
226 |
{host.circuits.length}
227 |
228 |
229 |
Status
230 |
{host.state}
231 |
232 |
233 |
Image
234 |
{host.image}
235 |
236 |
237 | )} 238 |
239 | ))} 240 |
241 |
242 |
243 | 244 | {/* HAProxy Statistics */} 245 | 246 | 247 | 248 | 249 | HAProxy Statistics 250 | 251 | 252 | 253 | {haproxyStats.length > 0 ? ( 254 |
255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | {haproxyStats.map((stat, index) => ( 268 | 269 | 270 | 275 | 276 | 277 | 278 | 279 | 280 | ))} 281 | 282 |
BackendStatusSessionsBytes InBytes OutRate
{stat.svname} 271 | 272 | {stat.status} 273 | 274 | {stat.stot.toLocaleString()}{formatBytes(stat.bin)}{formatBytes(stat.bout)}{stat.rate}/s
283 |
284 | ) : ( 285 |
286 | 287 |

No HAProxy statistics available

288 |
289 | )} 290 |
291 |
292 | 293 | {/* Export and Actions */} 294 | 295 | 296 | 297 | 298 | Diagnostic Actions 299 | 300 | 301 | 302 |
303 | 307 | 311 |
312 |
313 | Export diagnostics data for troubleshooting or sharing with support. 314 |
315 |
316 |
317 |
318 | ) 319 | } 320 | -------------------------------------------------------------------------------- /dashboard/src/components/CircuitMonitor.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from './ui/card' 2 | import { Badge } from './ui/badge' 3 | import { Button } from './ui/button' 4 | import type { DashboardData } from '../types' 5 | import { apiClient } from '../lib/api' 6 | import { useToast } from './ui/toast' 7 | import { 8 | Shield, 9 | MapPin, 10 | Clock, 11 | Activity, 12 | Globe, 13 | CheckCircle, 14 | XCircle, 15 | ArrowRight, 16 | RefreshCw, 17 | Trash2, 18 | Eye, 19 | EyeOff, 20 | ChevronDown, 21 | ChevronRight 22 | } from 'lucide-react' 23 | import { useState } from 'react' 24 | 25 | interface CircuitMonitorProps { 26 | data: DashboardData 27 | } 28 | 29 | export function CircuitMonitor({ data }: CircuitMonitorProps) { 30 | const { torHosts } = data 31 | const [expandedCircuits, setExpandedCircuits] = useState>(new Set()) 32 | const [showClosedCircuits, setShowClosedCircuits] = useState(false) 33 | const [selectedHost, setSelectedHost] = useState(null) 34 | const { addToast } = useToast() 35 | 36 | const allCircuits = torHosts.flatMap(host => 37 | host.circuits.map(circuit => ({ 38 | ...circuit, 39 | hostname: host.hostname, 40 | hostId: host.id, 41 | hostError: host.error 42 | })) 43 | ) 44 | 45 | const activeCircuits = allCircuits.filter(c => c.purpose !== 'CLOSED' && c.path.length > 0) 46 | const closedCircuits = allCircuits.filter(c => c.purpose === 'CLOSED') 47 | const buildingCircuits = allCircuits.filter(c => c.purpose === 'BUILDING') 48 | 49 | const filteredCircuits = selectedHost 50 | ? allCircuits.filter(c => c.hostId === selectedHost) 51 | : showClosedCircuits 52 | ? allCircuits 53 | : activeCircuits 54 | 55 | const circuitStats = { 56 | total: allCircuits.length, 57 | active: activeCircuits.length, 58 | closed: closedCircuits.length, 59 | building: buildingCircuits.length, 60 | byPurpose: allCircuits.reduce((acc, circuit) => { 61 | acc[circuit.purpose] = (acc[circuit.purpose] || 0) + 1 62 | return acc 63 | }, {} as Record) 64 | } 65 | 66 | const getCircuitStatusIcon = (purpose: string) => { 67 | switch (purpose) { 68 | case 'CLOSED': return 69 | case 'BUILDING': return 70 | default: return 71 | } 72 | } 73 | 74 | const getCircuitStatusColor = (purpose: string) => { 75 | switch (purpose) { 76 | case 'CLOSED': return 'destructive' 77 | case 'BUILDING': return 'secondary' 78 | default: return 'default' 79 | } 80 | } 81 | 82 | const toggleCircuitExpansion = (circuitId: string) => { 83 | const newExpanded = new Set(expandedCircuits) 84 | if (newExpanded.has(circuitId)) { 85 | newExpanded.delete(circuitId) 86 | } else { 87 | newExpanded.add(circuitId) 88 | } 89 | setExpandedCircuits(newExpanded) 90 | } 91 | 92 | const handleRebuildCircuit = async (_circuitId: string, hostId: string) => { 93 | try { 94 | const result = await apiClient.rebuildHostCircuits(hostId) 95 | addToast({ 96 | type: 'success', 97 | title: 'Circuits Rebuilt', 98 | description: result.message 99 | }) 100 | } catch (error) { 101 | addToast({ 102 | type: 'error', 103 | title: 'Rebuild Failed', 104 | description: error instanceof Error ? error.message : 'Failed to rebuild circuits' 105 | }) 106 | } 107 | } 108 | 109 | const handleCloseCircuit = async (circuitId: string, hostId: string) => { 110 | try { 111 | const result = await apiClient.closeCircuit(circuitId, hostId) 112 | addToast({ 113 | type: 'success', 114 | title: 'Circuit Closed', 115 | description: result.message 116 | }) 117 | } catch (error) { 118 | addToast({ 119 | type: 'error', 120 | title: 'Close Failed', 121 | description: error instanceof Error ? error.message : 'Failed to close circuit' 122 | }) 123 | } 124 | } 125 | 126 | const handleRebuildAllCircuits = async () => { 127 | try { 128 | const result = await apiClient.rebuildAllCircuits() 129 | addToast({ 130 | type: 'success', 131 | title: 'All Circuits Rebuilt', 132 | description: `Rebuilt circuits for ${result.results.length} hosts` 133 | }) 134 | } catch (error) { 135 | addToast({ 136 | type: 'error', 137 | title: 'Rebuild Failed', 138 | description: error instanceof Error ? error.message : 'Failed to rebuild all circuits' 139 | }) 140 | } 141 | } 142 | 143 | return ( 144 |
145 | {/* Circuit Statistics with Actions */} 146 |
147 | setSelectedHost(null)}> 148 | 149 | Total Circuits 150 | 151 | 152 | 153 |
{circuitStats.total}
154 |

All circuits

155 |
156 |
157 | 158 | setSelectedHost(null)}> 159 | 160 | Active 161 | 162 | 163 | 164 |
{circuitStats.active}
165 |

Ready for traffic

166 |
167 |
168 | 169 | setSelectedHost(null)}> 170 | 171 | Building 172 | 173 | 174 | 175 |
{circuitStats.building}
176 |

In progress

177 |
178 |
179 | 180 | setSelectedHost(null)}> 181 | 182 | Closed 183 | 184 | 185 | 186 |
{circuitStats.closed}
187 |

Terminated

188 |
189 |
190 |
191 | 192 | {/* Circuit Purpose Breakdown with Actions */} 193 | 194 | 195 | 196 |
197 | 198 | Circuit Purpose Breakdown 199 |
200 |
201 | 209 | 217 |
218 |
219 |
220 | 221 |
222 | {Object.entries(circuitStats.byPurpose).map(([purpose, count]) => ( 223 | 224 | {purpose}: {count} 225 | 226 | ))} 227 |
228 |
229 |
230 | 231 | {/* Host Filter */} 232 | 233 | 234 | 235 | 236 | Filter by Host 237 | 238 | 239 | 240 |
241 | 248 | {torHosts.map((host) => ( 249 | 257 | ))} 258 |
259 |
260 |
261 | 262 | {/* Circuit Details */} 263 | 264 | 265 | 266 | 267 | Circuit Details 268 | {selectedHost && ( 269 | 270 | {torHosts.find(h => h.id === selectedHost)?.hostname} 271 | 272 | )} 273 | 274 | 275 | 276 | {filteredCircuits.length > 0 ? ( 277 |
278 | {filteredCircuits.map((circuit) => ( 279 |
283 |
284 |
285 | {getCircuitStatusIcon(circuit.purpose)} 286 |
287 |

288 | Circuit {circuit.circuit_id.slice(0, 8)} 289 | 290 | {circuit.purpose} 291 | 292 |

293 |

294 | Host: {circuit.hostname} 295 |

296 |
297 |
298 |
299 |
300 | {circuit.path.length} nodes 301 |
302 | 312 |
313 |
314 | 315 | {/* Circuit Actions */} 316 |
317 | 325 | {circuit.purpose !== 'CLOSED' && ( 326 | 334 | )} 335 |
336 | 337 | {/* Expanded Circuit Path Visualization */} 338 | {expandedCircuits.has(circuit.circuit_id) && ( 339 |
340 |
Circuit Path:
341 |
342 | {circuit.path.map((node, nodeIndex) => ( 343 |
344 |
345 | 346 | 347 | {node.nickname || node.fingerprint.slice(0, 6)} 348 | 349 |
350 | 351 | {node.location.country !== 'unknown' && ( 352 |
353 | 354 | {node.location.country} 355 |
356 | )} 357 | 358 | {nodeIndex < circuit.path.length - 1 && ( 359 | 360 | )} 361 |
362 | ))} 363 |
364 |
365 | )} 366 |
367 | ))} 368 |
369 | ) : ( 370 |
371 | 372 |

No circuits found

373 |

374 | {selectedHost ? 'Try selecting a different host' : 'Circuits may be building or all hosts are offline'} 375 |

376 |
377 | )} 378 |
379 |
380 |
381 | ) 382 | } -------------------------------------------------------------------------------- /dashboard/src/components/SystemOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from './ui/card' 2 | import { Badge } from './ui/badge' 3 | import { Button } from './ui/button' 4 | import type { DashboardData } from '../types' 5 | import { formatBytes, formatLatency } from '../lib/utils' 6 | import { apiClient } from '../lib/api' 7 | import { useToast } from './ui/toast' 8 | import { 9 | Activity, 10 | Globe, 11 | Zap, 12 | Shield, 13 | Clock, 14 | TrendingUp, 15 | Server, 16 | ArrowRight, 17 | ChevronDown, 18 | RefreshCw 19 | } from 'lucide-react' 20 | import { useState } from 'react' 21 | 22 | interface SystemOverviewProps { 23 | data: DashboardData 24 | } 25 | 26 | export function SystemOverview({ data }: SystemOverviewProps) { 27 | const { summary, torHosts } = data 28 | const [expandedHosts, setExpandedHosts] = useState>(new Set()) 29 | const [showAllHosts, setShowAllHosts] = useState(true) // Show all hosts by default 30 | const { addToast } = useToast() 31 | 32 | const healthyHosts = torHosts.filter(h => !h.error && h.circuits.length > 0) 33 | 34 | const toggleHostExpansion = (hostId: string) => { 35 | const newExpanded = new Set(expandedHosts) 36 | if (newExpanded.has(hostId)) { 37 | newExpanded.delete(hostId) 38 | } else { 39 | newExpanded.add(hostId) 40 | } 41 | setExpandedHosts(newExpanded) 42 | } 43 | 44 | const handleRebuildCircuits = async (hostId: string) => { 45 | try { 46 | const result = await apiClient.rebuildHostCircuits(hostId) 47 | addToast({ 48 | type: 'success', 49 | title: 'Circuits Rebuilt', 50 | description: result.message 51 | }) 52 | } catch (error) { 53 | addToast({ 54 | type: 'error', 55 | title: 'Rebuild Failed', 56 | description: error instanceof Error ? error.message : 'Failed to rebuild circuits' 57 | }) 58 | } 59 | } 60 | 61 | const handleRebuildAllCircuits = async () => { 62 | try { 63 | const result = await apiClient.rebuildAllCircuits() 64 | addToast({ 65 | type: 'success', 66 | title: 'All Circuits Rebuilt', 67 | description: `Rebuilt circuits for ${result.results.length} hosts` 68 | }) 69 | } catch (error) { 70 | addToast({ 71 | type: 'error', 72 | title: 'Rebuild Failed', 73 | description: error instanceof Error ? error.message : 'Failed to rebuild all circuits' 74 | }) 75 | } 76 | } 77 | 78 | return ( 79 |
80 | {/* Key Metrics - Top Priority */} 81 |
82 | setShowAllHosts(!showAllHosts)}> 83 | 84 | System Health 85 | 86 | 87 | 88 |
89 | {healthyHosts.length}/{torHosts.length} 90 |
91 |

92 | Healthy hosts • Click to view all 93 |

94 |
95 |
96 | 97 | 98 | 99 | Active Circuits 100 | 101 | 102 | 103 |
104 | {summary.activeCircuits} 105 |
106 |

107 | of {summary.totalCircuits} total 108 |

109 |
110 |
111 | 112 | 113 | 114 | Throughput 115 | 116 | 117 | 118 |
119 | {formatBytes(summary.totalBytesOut)} 120 |
121 |

122 | Data transferred 123 |

124 |
125 |
126 | 127 | 128 | 129 | Avg Latency 130 | 131 | 132 | 133 |
134 | {formatLatency(summary.averageLatency)} 135 |
136 |

137 | Response time 138 |

139 |
140 |
141 |
142 | 143 | {/* System Architecture Flow */} 144 | 145 | 146 | 147 | 148 | System Architecture 149 | 150 | 151 | 152 |
153 | {/* SOCKS5 Ingress */} 154 |
155 |
156 | 157 |
158 |
159 |

SOCKS5 Ingress

160 |

Port 8080

161 | Active 162 |
163 |
164 | 165 | 166 | 167 | {/* HAProxy */} 168 |
169 |
170 | 171 |
172 |
173 |

HAProxy

174 |

Load Balancer

175 | Running 176 |
177 |
178 | 179 | 180 | 181 | {/* Tor Hosts */} 182 |
setShowAllHosts(!showAllHosts)}> 183 |
184 | 185 |
186 |
187 |

Tor Hosts

188 |

{healthyHosts.length} healthy

189 | 0 ? "default" : "destructive"} className="mt-1"> 190 | {healthyHosts.length > 0 ? "Active" : "Offline"} 191 | 192 |
193 |
194 | 195 | 196 | 197 | {/* Circuits */} 198 |
199 |
200 | 201 |
202 |
203 |

Circuits

204 |

{summary.activeCircuits} active

205 | 0 ? "default" : "secondary"} className="mt-1"> 206 | {summary.activeCircuits > 0 ? "Connected" : "Building"} 207 | 208 |
209 |
210 |
211 |
212 |
213 | 214 | {/* Host Status Summary - Improved Layout */} 215 |
216 | {/* Header with Actions */} 217 |
218 |

Tor Hosts ({torHosts.length})

219 |
220 | 227 | 235 |
236 |
237 | 238 | {/* Hosts Grid */} 239 |
240 | {(showAllHosts ? torHosts : torHosts.slice(0, 6)).map((host) => { 241 | const isHealthy = !host.error && host.circuits.length > 0 242 | const isError = !!host.error 243 | 244 | return ( 245 | toggleHostExpansion(host.id)} 253 | > 254 | 255 |
256 |
257 |
262 | {host.hostname} 263 |
264 | 275 |
276 |
277 | 278 |
279 |
280 | Circuits 281 | 282 | {host.circuits.length} 283 | 284 |
285 |
286 | Status 287 | 292 | {isHealthy ? 'Healthy' : isError ? 'Error' : 'No Circuits'} 293 | 294 |
295 | {host.external_ip && ( 296 |
297 | External IP 298 | 299 | {host.external_ip} 300 | 301 |
302 | )} 303 | {host.error && ( 304 |
305 | {host.error} 306 |
307 | )} 308 | {expandedHosts.has(host.id) && ( 309 |
310 |
311 |
Internal IP: {host.ip_address}
312 |
Image: {host.image}
313 |
State: {host.state}
314 |
315 | {host.circuits.length > 0 && ( 316 |
317 |
Recent Circuits:
318 | {host.circuits.slice(0, 2).map((circuit) => ( 319 |
320 | Circuit {circuit.circuit_id} - {circuit.purpose} 321 |
322 | ))} 323 | {host.circuits.length > 2 && ( 324 | 340 | )} 341 | {expandedHosts.has(host.id + '_circuits') && ( 342 |
343 | {host.circuits.slice(2).map((circuit) => ( 344 |
345 | Circuit {circuit.circuit_id} - {circuit.purpose} 346 |
347 | ))} 348 | 359 |
360 | )} 361 |
362 | )} 363 |
364 | )} 365 | {!expandedHosts.has(host.id) && ( 366 |
367 | 368 |
369 | )} 370 |
371 |
372 |
373 | ) 374 | })} 375 |
376 | 377 | {!showAllHosts && torHosts.length > 6 && ( 378 |
379 | 386 |
387 | )} 388 |
389 |
390 | ) 391 | } -------------------------------------------------------------------------------- /dashboard/src/components/NetworkTraffic.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from './ui/card' 2 | import { Badge } from './ui/badge' 3 | import { Button } from './ui/button' 4 | import type { DashboardData } from '../types' 5 | import { formatBytes } from '../lib/utils' 6 | import { 7 | TrendingUp, 8 | TrendingDown, 9 | Activity, 10 | Globe, 11 | Zap, 12 | Clock, 13 | ArrowUpRight, 14 | ArrowDownRight, 15 | Play, 16 | Pause, 17 | RotateCcw, 18 | ChevronDown, 19 | ChevronRight 20 | } from 'lucide-react' 21 | import { useState, useEffect } from 'react' 22 | 23 | interface NetworkTrafficProps { 24 | data: DashboardData 25 | } 26 | 27 | interface TrafficDataPoint { 28 | timestamp: number 29 | bytesIn: number 30 | bytesOut: number 31 | sessions: number 32 | latency: number 33 | } 34 | 35 | export function NetworkTraffic({ data }: NetworkTrafficProps) { 36 | const [trafficHistory, setTrafficHistory] = useState([]) 37 | const [isAnimating, setIsAnimating] = useState(false) 38 | const [isPaused, setIsPaused] = useState(false) 39 | const [expandedMetrics, setExpandedMetrics] = useState>(new Set()) 40 | const [selectedTimeRange, setSelectedTimeRange] = useState<'1m' | '5m' | '15m' | '1h'>('5m') 41 | 42 | // Simulate real-time traffic updates 43 | useEffect(() => { 44 | if (isPaused) return 45 | 46 | const interval = setInterval(() => { 47 | const now = Date.now() 48 | const newPoint: TrafficDataPoint = { 49 | timestamp: now, 50 | bytesIn: data.summary.totalBytesIn + Math.random() * 1000000, 51 | bytesOut: data.summary.totalBytesOut + Math.random() * 1000000, 52 | sessions: data.summary.totalSessions + Math.floor(Math.random() * 10), 53 | latency: data.summary.averageLatency + (Math.random() - 0.5) * 50 54 | } 55 | 56 | setTrafficHistory(prev => { 57 | const updated = [...prev, newPoint].slice(-20) // Keep last 20 points 58 | setIsAnimating(true) 59 | setTimeout(() => setIsAnimating(false), 500) 60 | return updated 61 | }) 62 | }, 2000) 63 | 64 | return () => clearInterval(interval) 65 | }, [data.summary, isPaused]) 66 | 67 | const currentTraffic = trafficHistory[trafficHistory.length - 1] || { 68 | bytesIn: data.summary.totalBytesIn, 69 | bytesOut: data.summary.totalBytesOut, 70 | sessions: data.summary.totalSessions, 71 | latency: data.summary.averageLatency 72 | } 73 | 74 | const previousTraffic = trafficHistory[trafficHistory.length - 2] || currentTraffic 75 | 76 | const bytesInRate = currentTraffic.bytesIn - previousTraffic.bytesIn 77 | const bytesOutRate = currentTraffic.bytesOut - previousTraffic.bytesOut 78 | const sessionsRate = currentTraffic.sessions - previousTraffic.sessions 79 | const latencyChange = currentTraffic.latency - previousTraffic.latency 80 | 81 | const toggleMetricExpansion = (metric: string) => { 82 | const newExpanded = new Set(expandedMetrics) 83 | if (newExpanded.has(metric)) { 84 | newExpanded.delete(metric) 85 | } else { 86 | newExpanded.add(metric) 87 | } 88 | setExpandedMetrics(newExpanded) 89 | } 90 | 91 | const resetTrafficData = () => { 92 | setTrafficHistory([]) 93 | } 94 | 95 | return ( 96 |
97 | {/* Real-time Traffic Metrics with Controls */} 98 |
99 | toggleMetricExpansion('bytesIn')}> 100 | 101 | Data In 102 |
103 | 104 | {expandedMetrics.has('bytesIn') ? 105 | : 106 | 107 | } 108 |
109 |
110 | 111 |
112 | {formatBytes(currentTraffic.bytesIn)} 113 |
114 |
115 | 116 | {formatBytes(bytesInRate)}/s 117 |
118 | {expandedMetrics.has('bytesIn') && ( 119 |
120 |
Peak: {formatBytes(Math.max(...trafficHistory.map(t => t.bytesIn), data.summary.totalBytesIn))}
121 |
Avg: {formatBytes(trafficHistory.reduce((sum, t) => sum + t.bytesIn, 0) / Math.max(trafficHistory.length, 1))}
122 |
123 | )} 124 |
125 |
126 | 127 | toggleMetricExpansion('bytesOut')}> 128 | 129 | Data Out 130 |
131 | 132 | {expandedMetrics.has('bytesOut') ? 133 | : 134 | 135 | } 136 |
137 |
138 | 139 |
140 | {formatBytes(currentTraffic.bytesOut)} 141 |
142 |
143 | 144 | {formatBytes(bytesOutRate)}/s 145 |
146 | {expandedMetrics.has('bytesOut') && ( 147 |
148 |
Peak: {formatBytes(Math.max(...trafficHistory.map(t => t.bytesOut), data.summary.totalBytesOut))}
149 |
Avg: {formatBytes(trafficHistory.reduce((sum, t) => sum + t.bytesOut, 0) / Math.max(trafficHistory.length, 1))}
150 |
151 | )} 152 |
153 |
154 | 155 | toggleMetricExpansion('sessions')}> 156 | 157 | Active Sessions 158 |
159 | 160 | {expandedMetrics.has('sessions') ? 161 | : 162 | 163 | } 164 |
165 |
166 | 167 |
168 | {currentTraffic.sessions.toLocaleString()} 169 |
170 |
171 | {sessionsRate > 0 ? '+' : ''}{sessionsRate} new 172 |
173 | {expandedMetrics.has('sessions') && ( 174 |
175 |
Peak: {Math.max(...trafficHistory.map(t => t.sessions), data.summary.totalSessions).toLocaleString()}
176 |
Avg: {Math.round(trafficHistory.reduce((sum, t) => sum + t.sessions, 0) / Math.max(trafficHistory.length, 1)).toLocaleString()}
177 |
178 | )} 179 |
180 |
181 | 182 | toggleMetricExpansion('latency')}> 183 | 184 | Latency 185 |
186 | 187 | {expandedMetrics.has('latency') ? 188 | : 189 | 190 | } 191 |
192 |
193 | 194 |
195 | {currentTraffic.latency.toFixed(0)}ms 196 |
197 |
0 ? 'text-red-600' : 'text-green-600' 199 | }`}> 200 | {latencyChange > 0 ? '+' : ''}{latencyChange.toFixed(0)}ms 201 |
202 | {expandedMetrics.has('latency') && ( 203 |
204 |
Peak: {Math.max(...trafficHistory.map(t => t.latency), data.summary.averageLatency).toFixed(0)}ms
205 |
Avg: {(trafficHistory.reduce((sum, t) => sum + t.latency, 0) / Math.max(trafficHistory.length, 1)).toFixed(0)}ms
206 |
207 | )} 208 |
209 |
210 |
211 | 212 | {/* Traffic Controls */} 213 | 214 | 215 | 216 |
217 | 218 | Traffic Monitoring Controls 219 |
220 |
221 | 229 | 237 |
238 |
239 |
240 | 241 |
242 |
243 | Time Range: 244 |
245 | {(['1m', '5m', '15m', '1h'] as const).map((range) => ( 246 | 254 | ))} 255 |
256 |
257 |
258 |
259 | 260 | {isPaused ? 'Paused' : 'Live Updates'} 261 | 262 |
263 |
264 | 265 | 266 | 267 | {/* Traffic Flow Visualization */} 268 | 269 | 270 | 271 | 272 | Traffic Flow 273 | 274 | 275 | 276 |
277 | {/* Ingress Flow */} 278 |
279 |
280 |
281 | SOCKS5 Ingress 282 | Port 8080 283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 | 291 |
292 |
293 | 294 |
295 |
296 | 297 | {/* HAProxy Distribution */} 298 |
299 |
300 |
301 | HAProxy Load Balancer 302 | {data.summary.healthyBackends}/{data.summary.totalBackends} backends 303 |
304 |
305 | {Array.from({ length: data.summary.healthyBackends }).map((_, i) => ( 306 |
311 | ))} 312 |
313 |
314 | 315 |
316 |
317 | 318 |
319 |
320 | 321 | {/* Tor Hosts Distribution */} 322 |
323 | {data.torHosts.map((host) => ( 324 |
325 |
326 |
0 ? 'bg-green-500' : 'bg-yellow-500' 329 | } ${host.circuits.length > 0 ? 'animate-pulse' : ''}`} /> 330 | {host.hostname} 331 |
332 |
333 |
334 | {host.circuits.length} circuits 335 |
336 |
337 | {Array.from({ length: Math.min(host.circuits.length, 5) }).map((_, i) => ( 338 |
343 | ))} 344 |
345 |
346 |
347 | ))} 348 |
349 |
350 | 351 | 352 | 353 | {/* Performance Trends */} 354 | 355 | 356 | 357 | 358 | Performance Trends 359 | 360 | 361 | 362 |
363 |
364 | Real-time performance metrics (last 20 data points) 365 |
366 | 367 | {/* Simple ASCII-style chart */} 368 |
369 |
370 | Throughput (bytes/s) 371 | 372 | {trafficHistory.length > 0 ? 'Live' : 'No data'} 373 | 374 |
375 |
376 | {trafficHistory.slice(-10).map((point, index) => { 377 | const height = Math.min(100, (point.bytesOut / 1000000) * 10) // Scale to 0-100 378 | return ( 379 |
384 | ) 385 | })} 386 |
387 |
388 | 389 |
390 |
391 |
Peak Throughput
392 |
393 | {formatBytes(Math.max(...trafficHistory.map(t => t.bytesOut), data.summary.totalBytesOut))} 394 |
395 |
396 |
397 |
Current Load
398 |
399 | {data.summary.healthyBackends > 0 400 | ? `${Math.round((data.summary.totalSessions / data.summary.healthyBackends) * 100)}%` 401 | : '0%' 402 | } 403 |
404 |
405 |
406 |
407 | 408 | 409 |
410 | ) 411 | } -------------------------------------------------------------------------------- /metrics/app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi.middleware.gzip import GZipMiddleware 4 | from stem import CircStatus 5 | from stem.control import Controller 6 | import geoip2.database 7 | import requests 8 | import os 9 | from io import StringIO 10 | import csv 11 | import docker 12 | import asyncio 13 | import json 14 | from typing import List, Dict, Any 15 | from datetime import datetime, timedelta 16 | import logging 17 | from functools import lru_cache 18 | import time 19 | 20 | logging.basicConfig(level=logging.INFO) 21 | logger = logging.getLogger(__name__) 22 | logging.getLogger('stem').setLevel(logging.WARNING) 23 | 24 | app = FastAPI(title="multisocks Metrics API", version="1.0.0") 25 | app.add_middleware(GZipMiddleware, minimum_size=1000) 26 | hostname = os.getenv("ALLOWED_ORIGINS", "localhost") 27 | allowed_origins = [ 28 | f"http://{hostname}:3000", 29 | f"http://{hostname}:5173", 30 | f"https://{hostname}:3000", 31 | f"https://{hostname}:5173", 32 | "http://localhost:3000", 33 | "http://localhost:5173" 34 | ] 35 | 36 | if hostname == "*": 37 | allowed_origins = ["*"] 38 | 39 | app.add_middleware( 40 | CORSMiddleware, 41 | allow_origins=allowed_origins, 42 | allow_credentials=True, 43 | allow_methods=["*"], 44 | allow_headers=["*"], 45 | ) 46 | 47 | class ConnectionManager: 48 | def __init__(self): 49 | self.active_connections: List[WebSocket] = [] 50 | 51 | async def connect(self, websocket: WebSocket): 52 | await websocket.accept() 53 | self.active_connections.append(websocket) 54 | 55 | def disconnect(self, websocket: WebSocket): 56 | self.active_connections.remove(websocket) 57 | 58 | async def broadcast(self, data: dict): 59 | for connection in self.active_connections: 60 | try: 61 | await connection.send_text(json.dumps(data)) 62 | except: 63 | pass 64 | 65 | manager = ConnectionManager() 66 | 67 | def get_tor_containers(): 68 | try: 69 | client = docker.from_env() 70 | containers = client.containers.list(filters={"network": "net_tor"}) 71 | tor_hosts = [] 72 | for container in containers: 73 | if container.attrs.get('Config', {}).get('User') == 'tor': 74 | tor_hosts.append({ 75 | "id": container.short_id, 76 | "ip_address": container.attrs['NetworkSettings']['Networks']['net_tor']['IPAddress'], 77 | "hostname": container.name, 78 | "image": container.image.tags[0] if container.image.tags else container.image.short_id, 79 | "state": container.status 80 | }) 81 | return sorted(tor_hosts, key=lambda x: x['hostname']) 82 | except Exception as e: 83 | logger.error(f"error getting Tor containers: {e}") 84 | return [] 85 | 86 | def get_haproxy_stats(): 87 | try: 88 | response = requests.get("http://haproxy:1337/;csv", timeout=5) 89 | response.raise_for_status() 90 | csv_data = StringIO(response.text) 91 | reader = csv.DictReader(csv_data) 92 | backends = [] 93 | for row in reader: 94 | if row['# pxname'] == 'tors': 95 | backends.append({ 96 | "pxname": row['# pxname'], 97 | "svname": row['svname'], 98 | "status": row['status'], 99 | "scur": int(row['scur']) if row['scur'].isdigit() else 0, 100 | "smax": int(row['smax']) if row['smax'].isdigit() else 0, 101 | "stot": int(row['stot']) if row['stot'].isdigit() else 0, 102 | "bin": int(row['bin']) if row['bin'].isdigit() else 0, 103 | "bout": int(row['bout']) if row['bout'].isdigit() else 0, 104 | "ereq": int(row['ereq']) if row['ereq'].isdigit() else 0, 105 | "econ": int(row['econ']) if row['econ'].isdigit() else 0, 106 | "eresp": int(row['eresp']) if row['eresp'].isdigit() else 0, 107 | "wretr": int(row['wretr']) if row['wretr'].isdigit() else 0, 108 | "wredis": int(row['wredis']) if row['wredis'].isdigit() else 0, 109 | "weight": int(row['weight']) if row['weight'].isdigit() else 0, 110 | "act": int(row['act']) if row['act'].isdigit() else 0, 111 | "bck": int(row['bck']) if row['bck'].isdigit() else 0, 112 | "chkfail": int(row['chkfail']) if row['chkfail'].isdigit() else 0, 113 | "chkdown": int(row['chkdown']) if row['chkdown'].isdigit() else 0, 114 | "downtime": int(row['downtime']) if row['downtime'].isdigit() else 0, 115 | "rate": int(row['rate']) if row['rate'].isdigit() else 0, 116 | "rate_max": int(row['rate_max']) if row['rate_max'].isdigit() else 0, 117 | "hrsp_2xx": int(row['hrsp_2xx']) if row['hrsp_2xx'].isdigit() else 0, 118 | "hrsp_3xx": int(row['hrsp_3xx']) if row['hrsp_3xx'].isdigit() else 0, 119 | "hrsp_4xx": int(row['hrsp_4xx']) if row['hrsp_4xx'].isdigit() else 0, 120 | "hrsp_5xx": int(row['hrsp_5xx']) if row['hrsp_5xx'].isdigit() else 0, 121 | "cli_abrt": int(row['cli_abrt']) if row['cli_abrt'].isdigit() else 0, 122 | "srv_abrt": int(row['srv_abrt']) if row['srv_abrt'].isdigit() else 0, 123 | "lastsess": int(row['lastsess']) if row['lastsess'].isdigit() else 0, 124 | "qtime": int(row['qtime']) if row['qtime'].isdigit() else 0, 125 | "ctime": int(row['ctime']) if row['ctime'].isdigit() else 0, 126 | "rtime": int(row['rtime']) if row['rtime'].isdigit() else 0, 127 | "ttime": int(row['ttime']) if row['ttime'].isdigit() else 0, 128 | "check_status": row['check_status'], 129 | "check_code": int(row['check_code']) if row['check_code'].isdigit() else 0, 130 | "check_duration": int(row['check_duration']) if row['check_duration'].isdigit() else 0, 131 | "last_chk": row['last_chk'], 132 | "qtime_max": int(row['qtime_max']) if row['qtime_max'].isdigit() else 0, 133 | "ctime_max": int(row['ctime_max']) if row['ctime_max'].isdigit() else 0, 134 | "rtime_max": int(row['rtime_max']) if row['rtime_max'].isdigit() else 0, 135 | "ttime_max": int(row['ttime_max']) if row['ttime_max'].isdigit() else 0 136 | }) 137 | return {"backends": backends} 138 | except Exception as e: 139 | logger.error(f"error getting HAProxy stats: {e}") 140 | return {"backends": []} 141 | 142 | external_ip_cache = {} 143 | CACHE_DURATION = 300 144 | @lru_cache(maxsize=128) 145 | def get_geoip_location(address: str): 146 | try: 147 | reader = geoip2.database.Reader('GeoLite2-City.mmdb') 148 | response = reader.city(address) 149 | return { 150 | "city": response.city.name, 151 | "country": response.country.name, 152 | "latitude": float(response.location.latitude), 153 | "longitude": float(response.location.longitude) 154 | } 155 | except FileNotFoundError: 156 | logger.warning("GeoLite2-City.mmdb not found. download from https://dev.maxmind.com/geoip/geoip2/geolite2/") 157 | return {"city": "Unknown", "country": "Unknown", "latitude": 0, "longitude": 0} 158 | except Exception as e: 159 | logger.warning(f"GeoIP lookup failed: {e}") 160 | return {"city": "Unknown", "country": "Unknown", "latitude": 0, "longitude": 0} 161 | 162 | dashboard_cache = {} 163 | DASHBOARD_CACHE_DURATION = 2 164 | 165 | def get_tor_external_ip(host_ip: str): 166 | import time 167 | cache_key = host_ip 168 | if cache_key in external_ip_cache: 169 | cached_data = external_ip_cache[cache_key] 170 | if time.time() - cached_data['timestamp'] < CACHE_DURATION: 171 | return cached_data['ip'] 172 | try: 173 | import requests 174 | session = requests.Session() 175 | session.proxies = { 176 | 'http': f'socks5://{host_ip}:9050', 177 | 'https': f'socks5://{host_ip}:9050' 178 | } 179 | response = session.get('https://cloudflare.com/cdn-cgi/trace', timeout=5) 180 | if response.status_code == 200: 181 | for line in response.text.split('\n'): 182 | if line.startswith('ip='): 183 | ip = line.split('=')[1] 184 | external_ip_cache[cache_key] = { 185 | 'ip': ip, 186 | 'timestamp': time.time() 187 | } 188 | return ip 189 | return None 190 | except Exception as e: 191 | logger.warning(f"could not get external IP for {host_ip}: {e}") 192 | return None 193 | 194 | def get_tor_host_circuits(host_id: str): 195 | try: 196 | reader = geoip2.database.Reader('GeoLite2-City.mmdb') 197 | except FileNotFoundError: 198 | logger.error("GeoLite2-City.mmdb not found") 199 | return { 200 | "error": "GeoLite2-City.mmdb not found. Download from https://dev.maxmind.com/geoip/geoip2/geolite2/ and place it in the app directory." 201 | } 202 | 203 | tor_hosts = get_tor_containers() 204 | tor_host = next((host for host in tor_hosts if host['id'] == host_id), None) 205 | 206 | if not tor_host: 207 | return {"error": "Tor host not found"} 208 | 209 | host_info = { 210 | "ip_address": tor_host["ip_address"], 211 | "hostname": tor_host["hostname"], 212 | "image": tor_host["image"], 213 | "state": tor_host["state"], 214 | "circuits": [] 215 | } 216 | max_retries = 3 217 | retry_delay = 1 218 | for attempt in range(max_retries): 219 | try: 220 | with Controller.from_port(address=tor_host["ip_address"], port=9051) as controller: 221 | controller.authenticate(password="log4j2.enableJndiLookup") 222 | for circ in sorted(controller.get_circuits()): 223 | if circ.status != CircStatus.BUILT: 224 | continue 225 | circuit_info = { 226 | "circuit_id": circ.id, 227 | "purpose": circ.purpose, 228 | "path": [] 229 | } 230 | for i, entry in enumerate(circ.path): 231 | fingerprint, nickname = entry 232 | desc = controller.get_network_status(fingerprint, None) 233 | address = desc.address if desc else 'unknown' 234 | try: 235 | response = reader.city(address) 236 | location = { 237 | "country": response.country.name, 238 | "city": response.city.name, 239 | "latitude": response.location.latitude, 240 | "longitude": response.location.longitude 241 | } 242 | except Exception: 243 | location = { 244 | "country": "unknown", 245 | "city": "unknown", 246 | "latitude": None, 247 | "longitude": None 248 | } 249 | 250 | circuit_info["path"].append({ 251 | "fingerprint": fingerprint, 252 | "nickname": nickname, 253 | "address": address, 254 | "location": location 255 | }) 256 | 257 | host_info["circuits"].append(circuit_info) 258 | if host_info["circuits"]: 259 | for circuit in host_info["circuits"]: 260 | if circuit["path"] and len(circuit["path"]) > 0: 261 | exit_node = circuit["path"][-1] 262 | host_info["external_ip"] = exit_node["address"] 263 | break 264 | break 265 | 266 | except Exception as e: 267 | logger.warning(f"Attempt {attempt + 1} failed for {host_id}: {e}") 268 | if attempt < max_retries - 1: 269 | time.sleep(retry_delay) 270 | retry_delay *= 2 271 | else: 272 | logger.error(f"All attempts failed for {host_id}: {e}") 273 | host_info["error"] = str(e) 274 | 275 | return host_info 276 | 277 | def calculate_summary(tor_hosts: List[Dict], haproxy_stats: List[Dict]) -> Dict[str, Any]: 278 | total_circuits = sum(len(host.get('circuits', [])) for host in tor_hosts) 279 | active_circuits = sum(len([c for c in host.get('circuits', []) if c.get('purpose') != 'CLOSED']) for host in tor_hosts) 280 | total_sessions = sum(stat.get('stot', 0) for stat in haproxy_stats) 281 | total_bytes_in = sum(stat.get('bin', 0) for stat in haproxy_stats) 282 | total_bytes_out = sum(stat.get('bout', 0) for stat in haproxy_stats) 283 | average_latency = 999999 #TODO 284 | healthy_backends = len([stat for stat in haproxy_stats if stat.get('status') == 'UP']) 285 | total_backends = len(haproxy_stats) 286 | return { 287 | "totalCircuits": total_circuits, 288 | "activeCircuits": active_circuits, 289 | "totalSessions": total_sessions, 290 | "totalBytesIn": total_bytes_in, 291 | "totalBytesOut": total_bytes_out, 292 | "averageLatency": average_latency, 293 | "healthyBackends": healthy_backends, 294 | "totalBackends": total_backends, 295 | "uptime": 0 #TODO 296 | } 297 | 298 | def rebuild_circuits_for_host(host_id: str): 299 | """Rebuild all circuits for a specific Tor host""" 300 | try: 301 | tor_hosts = get_tor_containers() 302 | tor_host = next((host for host in tor_hosts if host['id'] == host_id), None) 303 | if not tor_host: 304 | return {"error": "tor host not found"} 305 | 306 | with Controller.from_port(address=tor_host["ip_address"], port=9051) as controller: 307 | controller.authenticate(password="log4j2.enableJndiLookup") 308 | circuits = controller.get_circuits() 309 | for circ in circuits: 310 | if circ.status == CircStatus.BUILT: 311 | try: 312 | controller.close_circuit(circ.id) 313 | except Exception as e: 314 | logger.warning(f"could not close circuit {circ.id}: {e}") 315 | 316 | controller.signal("NEWNYM") 317 | return {"success": True, "message": f"rebuilding circuits for {tor_host['hostname']}"} 318 | 319 | except Exception as e: 320 | logger.error(f"error rebuilding circuits for host {host_id}: {e}") 321 | return {"error": str(e)} 322 | 323 | def close_circuit(host_id: str, circuit_id: str): 324 | """close a specific circuit""" 325 | try: 326 | tor_hosts = get_tor_containers() 327 | tor_host = next((host for host in tor_hosts if host['id'] == host_id), None) 328 | 329 | if not tor_host: 330 | return {"error": "Tor host not found"} 331 | 332 | with Controller.from_port(address=tor_host["ip_address"], port=9051) as controller: 333 | controller.authenticate(password="log4j2.enableJndiLookup") 334 | circuit = next((c for c in controller.get_circuits() if c.id == circuit_id), None) 335 | if not circuit: 336 | return {"error": "circuit not found"} 337 | controller.close_circuit(circuit_id) 338 | return {"success": True, "message": f"closed circuit {circuit_id}"} 339 | 340 | except Exception as e: 341 | logger.error(f"error closing circuit {circuit_id} on host {host_id}: {e}") 342 | return {"error": str(e)} 343 | 344 | def rebuild_all_circuits(): 345 | """rebuild circuits for all Tor hosts""" 346 | try: 347 | tor_hosts = get_tor_containers() 348 | results = [] 349 | for host in tor_hosts: 350 | result = rebuild_circuits_for_host(host['id']) 351 | results.append({ 352 | "host_id": host['id'], 353 | "hostname": host['hostname'], 354 | "result": result 355 | }) 356 | 357 | return {"success": True, "results": results} 358 | 359 | except Exception as e: 360 | logger.error(f"Error rebuilding all circuits: {e}") 361 | return {"error": str(e)} 362 | 363 | @app.get("/") 364 | async def root(): 365 | return {"message": "multisocks metrics", "version": "1.0.0"} 366 | 367 | @app.get("/health") 368 | def health_check(): 369 | return {"status": "healthy", "timestamp": datetime.now().isoformat()} 370 | 371 | @app.get("/tor-hosts") 372 | def list_tor_hosts(): 373 | tor_hosts = get_tor_containers() 374 | return tor_hosts 375 | 376 | @app.get("/haproxy-stats") 377 | def get_haproxy_stats_endpoint(): 378 | return get_haproxy_stats() 379 | 380 | @app.get("/tor-hosts/{host_id}/circuits") 381 | def get_tor_host_circuits_endpoint(host_id: str): 382 | return get_tor_host_circuits(host_id) 383 | 384 | 385 | @app.get("/dashboard-data") 386 | def get_dashboard_data(): 387 | cache_key = "dashboard_data" 388 | current_time = time.time() 389 | if cache_key in dashboard_cache: 390 | cached_data, timestamp = dashboard_cache[cache_key] 391 | if current_time - timestamp < DASHBOARD_CACHE_DURATION: 392 | return cached_data 393 | tor_hosts = get_tor_containers() 394 | haproxy_data = get_haproxy_stats() 395 | haproxy_stats = haproxy_data.get('backends', []) 396 | tor_hosts_with_circuits = [] 397 | for host in tor_hosts: 398 | circuits_data = get_tor_host_circuits(host['id']) 399 | if 'error' in circuits_data: 400 | tor_hosts_with_circuits.append({ 401 | **host, 402 | "circuits": [], 403 | "error": circuits_data['error'] 404 | }) 405 | else: 406 | tor_hosts_with_circuits.append({ 407 | **host, 408 | "external_ip": circuits_data.get('external_ip'), 409 | "circuits": circuits_data.get('circuits', []) 410 | }) 411 | 412 | summary = calculate_summary(tor_hosts_with_circuits, haproxy_stats) 413 | 414 | result = { 415 | "torHosts": tor_hosts_with_circuits, 416 | "haproxyStats": haproxy_stats, 417 | "summary": summary, 418 | "lastUpdated": datetime.now().isoformat() 419 | } 420 | dashboard_cache[cache_key] = (result, current_time) 421 | return result 422 | 423 | @app.post("/tor-hosts/{host_id}/rebuild-circuits") 424 | def rebuild_host_circuits(host_id: str): 425 | """rebuild all circuits for a specific Tor host""" 426 | result = rebuild_circuits_for_host(host_id) 427 | if "error" in result: 428 | raise HTTPException(status_code=400, detail=result["error"]) 429 | return result 430 | 431 | @app.post("/circuits/{circuit_id}/close") 432 | def close_circuit_endpoint(circuit_id: str, host_id: str): 433 | """close a specific circuit""" 434 | result = close_circuit(host_id, circuit_id) 435 | if "error" in result: 436 | raise HTTPException(status_code=400, detail=result["error"]) 437 | return result 438 | 439 | @app.post("/circuits/rebuild-all") 440 | def rebuild_all_circuits_endpoint(): 441 | """rebuild circuits for all Tor hosts""" 442 | result = rebuild_all_circuits() 443 | if "error" in result: 444 | raise HTTPException(status_code=500, detail=result["error"]) 445 | return result 446 | 447 | @app.post("/tor-hosts/{host_id}/new-identity") 448 | def new_identity(host_id: str): 449 | """request new identity for a Tor host (triggers circuit rebuild)""" 450 | try: 451 | tor_hosts = get_tor_containers() 452 | tor_host = next((host for host in tor_hosts if host['id'] == host_id), None) 453 | if not tor_host: 454 | raise HTTPException(status_code=404, detail="Tor host not found") 455 | with Controller.from_port(address=tor_host["ip_address"], port=9051) as controller: 456 | controller.authenticate(password="log4j2.enableJndiLookup") 457 | controller.signal("NEWNYM") 458 | return {"success": True, "message": f"New identity requested for {tor_host['hostname']}"} 459 | 460 | except Exception as e: 461 | logger.error(f"Error requesting new identity for host {host_id}: {e}") 462 | raise HTTPException(status_code=500, detail=str(e)) 463 | 464 | @app.websocket("/ws") 465 | async def websocket_endpoint(websocket: WebSocket): 466 | await manager.connect(websocket) 467 | try: 468 | while True: 469 | dashboard_data = get_dashboard_data() 470 | await manager.broadcast({ 471 | "type": "dashboard_update", 472 | "data": dashboard_data, 473 | "timestamp": datetime.now().isoformat() 474 | }) 475 | await asyncio.sleep(5) 476 | except WebSocketDisconnect: 477 | manager.disconnect(websocket) 478 | --------------------------------------------------------------------------------