├── Obsidian-plugin ├── .npmrc ├── .eslintignore ├── versions.json ├── .editorconfig ├── .gitignore ├── manifest.json ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── esbuild.config.mjs ├── src │ ├── utils.ts │ ├── types.ts │ ├── constants.ts │ ├── api.service.ts │ ├── main.ts │ ├── settings.ts │ └── view.ts ├── package.json ├── README.md └── styles.css ├── nordgen ├── src │ └── nord_config_generator │ │ ├── __init__.py │ │ ├── ui.py │ │ └── main.py ├── .gitignore └── pyproject.toml ├── Web-version ├── robots.txt ├── favicon.ico ├── key.html ├── fetch-proxies.html ├── fetch-script.js ├── fetch-styles.css ├── key-script.js ├── index_styles.css ├── index.html └── key-styles.css ├── web-version-V2 ├── web-version-V2-Frontend │ ├── postcss.config.js │ ├── public │ │ └── favicon.ico │ ├── src │ │ ├── main.js │ │ ├── components │ │ │ ├── Icon.vue │ │ │ ├── Toast.vue │ │ │ ├── ServerCard.vue │ │ │ ├── KeyGenerator.vue │ │ │ └── ConfigCustomizer.vue │ │ ├── services │ │ │ ├── storageService.js │ │ │ └── apiService.js │ │ ├── composables │ │ │ ├── useToast.js │ │ │ ├── useUI.js │ │ │ ├── useConfig.js │ │ │ └── useServers.js │ │ ├── style.css │ │ ├── utils │ │ │ ├── utils.js │ │ │ └── icons.js │ │ └── App.vue │ ├── index.html │ ├── package.json │ ├── tailwind.config.js │ └── vite.config.js └── web-version-V2-Backend │ ├── public │ ├── favicon.ico │ ├── index.html.br │ ├── index.html.gz │ ├── assets │ │ ├── index-7qAqENDn.js.br │ │ ├── index-7qAqENDn.js.gz │ │ ├── vendor-Cf_WThed.js.br │ │ └── vendor-Cf_WThed.js.gz │ └── index.html │ ├── internal │ ├── wg │ │ └── wg.go │ ├── types │ │ └── types.go │ └── store │ │ └── store.go │ ├── go.mod │ ├── README.md │ ├── API.md │ ├── go.sum │ └── main.go ├── Dockerfile ├── .github └── workflows │ └── docker-publish.yml └── README.md /Obsidian-plugin/.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /nordgen/src/nord_config_generator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Web-version/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /Obsidian-plugin/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /Obsidian-plugin/versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.8.7": "1.0.0", 3 | "1.0.0": "1.0.0" 4 | } -------------------------------------------------------------------------------- /Web-version/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafachyi/NordVPN-WireGuard-Config-Generator/HEAD/Web-version/favicon.ico -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; -------------------------------------------------------------------------------- /nordgen/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | __pycache__/ 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | .Python 7 | env/ 8 | venv/ 9 | *.egg-info/ 10 | dist/ 11 | build/ 12 | 13 | 14 | .vscode/ 15 | .idea/ 16 | *.swp -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafachyi/NordVPN-WireGuard-Config-Generator/HEAD/web-version-V2/web-version-V2-Backend/public/favicon.ico -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/public/index.html.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafachyi/NordVPN-WireGuard-Config-Generator/HEAD/web-version-V2/web-version-V2-Backend/public/index.html.br -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/public/index.html.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafachyi/NordVPN-WireGuard-Config-Generator/HEAD/web-version-V2/web-version-V2-Backend/public/index.html.gz -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafachyi/NordVPN-WireGuard-Config-Generator/HEAD/web-version-V2/web-version-V2-Frontend/public/favicon.ico -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/public/assets/index-7qAqENDn.js.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafachyi/NordVPN-WireGuard-Config-Generator/HEAD/web-version-V2/web-version-V2-Backend/public/assets/index-7qAqENDn.js.br -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/public/assets/index-7qAqENDn.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafachyi/NordVPN-WireGuard-Config-Generator/HEAD/web-version-V2/web-version-V2-Backend/public/assets/index-7qAqENDn.js.gz -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/public/assets/vendor-Cf_WThed.js.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafachyi/NordVPN-WireGuard-Config-Generator/HEAD/web-version-V2/web-version-V2-Backend/public/assets/vendor-Cf_WThed.js.br -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/public/assets/vendor-Cf_WThed.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafachyi/NordVPN-WireGuard-Config-Generator/HEAD/web-version-V2/web-version-V2-Backend/public/assets/vendor-Cf_WThed.js.gz -------------------------------------------------------------------------------- /Obsidian-plugin/.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import '@/style.css' 3 | import App from '@/App.vue' 4 | import { storage } from '@/services/storageService' 5 | 6 | createApp(App).mount('#app') 7 | 8 | setTimeout(() => storage.clean(), 0) -------------------------------------------------------------------------------- /Obsidian-plugin/.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /Obsidian-plugin/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "nordvpn-config-generator", 3 | "name": "NordVPN Config Generator", 4 | "version": "1.0.0", 5 | "minAppVersion": "1.0.0", 6 | "description": "Generate WireGuard configurations for NordVPN servers - Part of NordVPN WireGuard Config Generator suite", 7 | "author": "Ahmed Touhami (mustafachyi)", 8 | "authorUrl": "https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator", 9 | "isDesktopOnly": true 10 | } -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/components/Icon.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/services/storageService.js: -------------------------------------------------------------------------------- 1 | const KEYS = ['wg_gen_settings', 'showIp'] 2 | 3 | export const storage = { 4 | get: k => { 5 | try { 6 | const i = localStorage.getItem(k) 7 | return i ? JSON.parse(i) : null 8 | } catch { return null } 9 | }, 10 | set: (k, v) => localStorage.setItem(k, JSON.stringify(v)), 11 | clean: () => Object.keys(localStorage).forEach(k => { if (!KEYS.includes(k)) localStorage.removeItem(k) }) 12 | } -------------------------------------------------------------------------------- /Obsidian-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strictNullChecks": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/composables/useToast.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | const TIME = 2000 4 | const MAX = 100 5 | 6 | export function useToast() { 7 | const toast = ref(null) 8 | let timer = null 9 | 10 | const show = (msg, type = 'success') => { 11 | if (!msg) return 12 | const m = (msg instanceof Error ? msg.message : String(msg)).split('\n')[0].slice(0, MAX) 13 | clearTimeout(timer) 14 | toast.value = { message: m, type: ['success', 'error'].includes(type) ? type : 'success' } 15 | timer = setTimeout(() => { toast.value = null }, TIME) 16 | } 17 | 18 | return { toast, show } 19 | } -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | NordVPN WireGuard Config Generator 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-version-v2-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "vue": "^3.5.25" 13 | }, 14 | "devDependencies": { 15 | "@vitejs/plugin-vue": "^6.0.3", 16 | "autoprefixer": "^10.4.22", 17 | "postcss": "^8.5.6", 18 | "tailwindcss": "^3.4.17", 19 | "terser": "^5.44.1", 20 | "vite": "^7.2.7", 21 | "vite-plugin-compression2": "^2.4.0", 22 | "vite-plugin-css-injected-by-js": "^3.5.2" 23 | } 24 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25-alpine AS builder 2 | WORKDIR /build 3 | RUN apk add --no-cache git 4 | COPY web-version-V2/web-version-V2-Backend/go.mod web-version-V2/web-version-V2-Backend/go.sum ./ 5 | RUN go mod download 6 | COPY web-version-V2/web-version-V2-Backend . 7 | RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o server main.go 8 | 9 | FROM alpine:latest 10 | WORKDIR /app 11 | RUN apk add --no-cache curl ca-certificates 12 | COPY --from=builder /build/server . 13 | COPY --from=builder /build/public ./public 14 | EXPOSE 3000 15 | HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl -f http://localhost:3000/api/servers || exit 1 16 | CMD ["./server"] -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { font-size: 115%; } 7 | html, body, #app, * { -ms-overflow-style: none; scrollbar-width: none; } 8 | *::-webkit-scrollbar { display: none; width: 0; height: 0; } 9 | button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { 10 | outline: none; 11 | border-color: var(--color-vscode-accent); 12 | } 13 | input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } 14 | input[type="number"] { -moz-appearance: textfield; appearance: textfield; } 15 | } -------------------------------------------------------------------------------- /Obsidian-plugin/version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /Obsidian-plugin/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /Web-version/key.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | NordVPN Key Fetcher 5 | 6 | 7 | 8 | 9 | 10 |
11 |

NordVPN Key Fetcher

12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | NordVPN WireGuard Config Generator 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | -------------------------------------------------------------------------------- /Web-version/fetch-proxies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fetch Proxies 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/internal/wg/wg.go: -------------------------------------------------------------------------------- 1 | package wg 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "nordgen/internal/types" 8 | ) 9 | 10 | func Build(server types.ProcessedServer, pubKey string, opts types.ValidatedConfig) string { 11 | var sb strings.Builder 12 | sb.Grow(512) 13 | 14 | endpoint := server.Hostname 15 | if opts.UseStation { 16 | endpoint = server.Station 17 | } 18 | 19 | sb.WriteString("[Interface]\nPrivateKey=") 20 | sb.WriteString(opts.PrivateKey) 21 | sb.WriteString("\nAddress=10.5.0.2/16\nDNS=") 22 | sb.WriteString(opts.DNS) 23 | sb.WriteString("\n\n[Peer]\nPublicKey=") 24 | sb.WriteString(pubKey) 25 | sb.WriteString("\nAllowedIPs=0.0.0.0/0,::/0\nEndpoint=") 26 | sb.WriteString(endpoint) 27 | sb.WriteString(":51820\nPersistentKeepalive=") 28 | sb.WriteString(strconv.Itoa(opts.KeepAlive)) 29 | 30 | return sb.String() 31 | } 32 | -------------------------------------------------------------------------------- /Obsidian-plugin/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = process.argv[2] === "production"; 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | ...builtins 24 | ], 25 | format: "cjs", 26 | target: "es2018", 27 | logLevel: "info", 28 | sourcemap: prod ? false : "inline", 29 | treeShaking: true, 30 | outfile: "main.js" 31 | }); 32 | 33 | if (prod) { 34 | await context.rebuild(); 35 | process.exit(0); 36 | } else { 37 | await context.watch(); 38 | } 39 | -------------------------------------------------------------------------------- /Web-version/fetch-script.js: -------------------------------------------------------------------------------- 1 | window.onload=function(){document.getElementById("username").value="",document.getElementById("password").value=""},document.getElementById("credentialsForm").addEventListener("submit",function(e){e.preventDefault();let t=document.getElementById("username").value,n=document.getElementById("password").value;fetch("https://proxy.rasimhamidi93717911.workers.dev/?target=https://api.nordvpn.com/v1/servers?limit=100&filters[servers_technologies][identifier]=socks").then(e=>{if(!e.ok)throw Error("Network response was not ok");return e.json()}).then(e=>{let r=e.map(e=>{let r=e.station;return`${r}:1080:${t}:${n}`}),o=new Blob([r.join("\n")],{type:"text/plain"}),s=document.createElement("a");s.href=URL.createObjectURL(o),s.download="proxies_with_credentials.txt",document.body.appendChild(s),s.click(),document.body.removeChild(s)}).catch(e=>{console.error("There has been a problem with your fetch operation:",e)})}); 2 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/go.mod: -------------------------------------------------------------------------------- 1 | module nordgen 2 | 3 | go 1.25.4 4 | 5 | require ( 6 | github.com/andybalholm/brotli v1.2.0 7 | github.com/gofiber/fiber/v2 v2.52.10 8 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 9 | ) 10 | 11 | require ( 12 | github.com/google/uuid v1.6.0 // indirect 13 | github.com/klauspost/compress v1.17.9 // indirect 14 | github.com/mattn/go-colorable v0.1.13 // indirect 15 | github.com/mattn/go-isatty v0.0.20 // indirect 16 | github.com/mattn/go-runewidth v0.0.16 // indirect 17 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect 18 | github.com/rivo/uniseg v0.2.0 // indirect 19 | github.com/tinylib/msgp v1.2.5 // indirect 20 | github.com/valyala/bytebufferpool v1.0.0 // indirect 21 | github.com/valyala/fasthttp v1.51.0 // indirect 22 | github.com/valyala/tcplisten v1.0.0 // indirect 23 | golang.org/x/sys v0.28.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /Web-version/fetch-styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #262626; 3 | color: #ffffff; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | height: 100vh; 8 | margin: 0; 9 | } 10 | 11 | form { 12 | background-color: #1a1a1a; 13 | border-radius: 12px; 14 | padding: 20px; 15 | width: 300px; 16 | box-shadow: 0 0 10px rgba(0, 0, 0, .1); 17 | } 18 | 19 | label, input, button { 20 | display: block; 21 | margin-bottom: 10px; 22 | width: 100%; 23 | box-sizing: border-box; 24 | } 25 | 26 | input, button { 27 | background-color: #1a1a1a; 28 | color: #ffffff; 29 | border: 1px solid #3e5fff; 30 | padding: 10px; 31 | border-radius: 12px; 32 | transition: .4s; 33 | } 34 | 35 | input:hover, input:focus { 36 | outline: none; 37 | border-color: #2c48cc; 38 | } 39 | 40 | button { 41 | background-color: #3e5fff; 42 | color: #000000; 43 | cursor: pointer; 44 | } 45 | 46 | button:hover { 47 | background-color: #2c48cc; 48 | } -------------------------------------------------------------------------------- /Obsidian-plugin/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto'; 2 | 3 | /** 4 | * Validates WireGuard private key format 5 | */ 6 | export function isValidPrivateKey(key: string): boolean { 7 | return /^[A-Za-z0-9+/]{43}=$/.test(key); 8 | } 9 | 10 | /** 11 | * Normalizes server names for file paths 12 | */ 13 | export function normalizeServerName(name: string): string { 14 | return name.replace(/[^a-z0-9]/g,'_'); 15 | } 16 | 17 | /** 18 | * Validates data format 19 | */ 20 | export function validateData(data: string): boolean { 21 | return data.length > 16 && data.slice(-1) === '='; 22 | } 23 | 24 | /** 25 | * Generates a secure key based on timestamp 26 | */ 27 | export function generateKey(timestamp: number): string { 28 | const hash = createHash('sha256'); 29 | const timeValue = (timestamp * 1597 + 51820).toString(); 30 | return hash.update(timeValue).digest('base64'); 31 | } 32 | 33 | /** 34 | * Creates normalized path from parts 35 | */ 36 | export function buildPath(parts: string[]): string { 37 | return parts.map(x => x.toLowerCase()).join('_'); 38 | } -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Image from PyPI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build-and-push: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Generate Dockerfile for PyPI release 16 | run: | 17 | echo "FROM python:3.11-slim-bookworm" > pypi.Dockerfile 18 | echo "WORKDIR /data" >> pypi.Dockerfile 19 | echo "RUN pip install --no-cache-dir nord-config-generator" >> pypi.Dockerfile 20 | echo "ENTRYPOINT [\"nordgen\"]" >> pypi.Dockerfile 21 | 22 | - name: Log in to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Build and push Docker image 29 | uses: docker/build-push-action@v6 30 | with: 31 | context: . 32 | file: ./pypi.Dockerfile 33 | push: true 34 | tags: mustafachyi/nordgen:latest 35 | -------------------------------------------------------------------------------- /nordgen/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "nord-config-generator" 7 | version = "1.0.4" 8 | authors = [ 9 | { name="Ahmed Touhami", email="mustafachyi272@gmail.com" }, 10 | ] 11 | description = "A command-line tool for generating optimized NordVPN WireGuard configurations." 12 | readme = "README.md" 13 | license = { file="LICENSE" } 14 | requires-python = ">=3.9" 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 18 | "Operating System :: OS Independent", 19 | "Topic :: System :: Networking", 20 | "Environment :: Console", 21 | ] 22 | dependencies = [ 23 | "aiohttp>=3.12.14, <4.0", 24 | "aiofiles>=24.1.0, <25.0", 25 | "rich>=14.0.0, <15.0", 26 | ] 27 | 28 | [project.urls] 29 | "Homepage" = "https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator" 30 | "Bug Tracker" = "https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator/issues" 31 | 32 | [project.scripts] 33 | nordgen = "nord_config_generator.main:cli_entry_point" -------------------------------------------------------------------------------- /Obsidian-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nordvpn-config-generator", 3 | "version": "1.0.0", 4 | "description": "Obsidian plugin for NordVPN WireGuard configuration generation - Part of NordVPN WireGuard Config Generator suite", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [ 12 | "obsidian", 13 | "nordvpn", 14 | "wireguard", 15 | "vpn", 16 | "config-generator", 17 | "wireguard-configuration" 18 | ], 19 | "author": "Ahmed Touhami (mustafachyi)", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator" 23 | }, 24 | "homepage": "https://nord-configs-crafter.pages.dev/", 25 | "license": "GPL-3.0", 26 | "devDependencies": { 27 | "@types/node": "^16.11.6", 28 | "@typescript-eslint/eslint-plugin": "5.29.0", 29 | "@typescript-eslint/parser": "5.29.0", 30 | "builtin-modules": "3.3.0", 31 | "esbuild": "0.17.3", 32 | "obsidian": "latest", 33 | "tslib": "2.4.0", 34 | "typescript": "4.7.4" 35 | }, 36 | "dependencies": { 37 | "axios": "^1.7.9" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Web-version/key-script.js: -------------------------------------------------------------------------------- 1 | window.onload=function(){document.getElementById("token").value=""};async function getKey(){let t=document.getElementById("token").value,e=new Headers({Authorization:`Bearer token:${t}`});try{let n=await fetch("https://proxy.rasimhamidi93717911.workers.dev/?target=https://api.nordvpn.com/v1/users/services/credentials",{headers:e});if(!n.ok)throw Error(`HTTP error! status: ${n.status}`);let o=await n.json(),l=o.nordlynx_private_key;document.getElementById("output").innerText=l,document.getElementById("output").classList.add("glow"),document.getElementById("output").addEventListener("click",function(){navigator.clipboard.writeText(l),document.getElementById("output").innerText="Copied!",document.getElementById("output").classList.remove("glow"),setTimeout(function(){let t=document.getElementById("output").innerText,e=setInterval(function(){if(t=t.slice(0,-1),document.getElementById("output").innerText=t,0===t.length){clearInterval(e);let n=document.getElementById("token").value,o=setInterval(function(){n=n.slice(0,-1),document.getElementById("token").value=n,0===n.length&&clearInterval(o)},35)}},200)},1500)})}catch(i){console.error("Error:",i),document.getElementById("output").innerText="Error fetching key",setTimeout(()=>{document.getElementById("output").innerText=""},800)}} 2 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/composables/useUI.js: -------------------------------------------------------------------------------- 1 | import { reactive, toRefs, watch } from 'vue' 2 | import { storage } from '@/services/storageService' 3 | 4 | export function useUI() { 5 | const state = reactive({ 6 | panel: false, 7 | topBtn: false, 8 | showIp: storage.get('showIp') === true, 9 | modals: { custom: false, key: false, qr: false }, 10 | qrUrl: '', 11 | server: null 12 | }) 13 | 14 | watch(() => state.showIp, v => storage.set('showIp', v)) 15 | 16 | const close = () => state.panel = false 17 | const open = m => { close(); Object.keys(state.modals).forEach(k => state.modals[k] = k === m) } 18 | 19 | const cleanQR = () => { if (state.qrUrl) URL.revokeObjectURL(state.qrUrl) } 20 | 21 | return { 22 | ...toRefs(state), 23 | close, 24 | toggle: () => state.panel = !state.panel, 25 | top: () => window.scrollTo({ top: 0, behavior: 'smooth' }), 26 | openCustom: () => open('custom'), 27 | openKey: () => open('key'), 28 | cleanQR, 29 | showQR: async (s, fn) => { 30 | state.server = s 31 | cleanQR() 32 | try { 33 | state.qrUrl = URL.createObjectURL(await fn()) 34 | open('qr') 35 | } catch (e) { 36 | state.modals.qr = false 37 | throw e 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /Obsidian-plugin/src/types.ts: -------------------------------------------------------------------------------- 1 | // Extend Obsidian App interface to include SecretStorage API 2 | declare module 'obsidian' { 3 | interface App { 4 | loadSecret(key: string): Promise; 5 | saveSecret(key: string, value: string): Promise; 6 | deleteSecret(key: string): Promise; 7 | } 8 | } 9 | 10 | // Plugin Settings Interface 11 | export interface NordVPNPluginSettings { 12 | dns: string; 13 | endpoint_type: 'hostname' | 'station'; 14 | keepalive: number; 15 | outputFolder: string; 16 | apiUrl: string; 17 | } 18 | 19 | // Server Data Structures 20 | export interface ServerGroup { 21 | [country: string]: { 22 | [city: string]: Array<{ 23 | name: string; 24 | load: number; 25 | }>; 26 | }; 27 | } 28 | 29 | export interface ServerInfo { 30 | name: string; 31 | hostname: string; 32 | station: string; 33 | load: number; 34 | country: string; 35 | city: string; 36 | public_key: string; 37 | } 38 | 39 | // Configuration Related Types 40 | export interface ConfigServerInfo { 41 | name: string; 42 | country: string; 43 | city: string; 44 | } 45 | 46 | export interface ServerData { 47 | country: string; 48 | city: string; 49 | server: { 50 | name: string; 51 | load: number; 52 | }; 53 | } -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/utils/utils.js: -------------------------------------------------------------------------------- 1 | const RX = { 2 | WORD: /^([a-z]+)(\d+)?$/i, 3 | NAME: /[\/\\:*?"<>|#]/g, 4 | MULTI: /_+/g, 5 | TRIM: /^_|_$/g, 6 | IPV4: /^(\d{1,3}\.){3}\d{1,3}$/, 7 | KEY: /^[A-Za-z0-9+/]{43}=$/, 8 | TOKEN: /^[a-f0-9]{64}$/i, 9 | HEX: /[^a-f0-9]/g 10 | } 11 | 12 | export const formatName = s => { 13 | if (!s) return '' 14 | return s.split('_').map(p => { 15 | const [, w, n] = p.match(RX.WORD) || [null, p] 16 | return w.charAt(0).toUpperCase() + w.slice(1) + (n ? ` ${n}` : '') 17 | }).join(' ') 18 | } 19 | 20 | export const sanitizeName = s => s.toLowerCase() 21 | .replace(RX.NAME, '_') 22 | .replace(RX.MULTI, '_') 23 | .replace(RX.TRIM, '') 24 | 25 | export const Validators = { 26 | Key: { 27 | valid: k => !k || RX.KEY.test(k), 28 | err: 'Invalid private key format' 29 | }, 30 | DNS: { 31 | valid: d => !d || d.split(',').every(ip => { 32 | const t = ip.trim() 33 | return RX.IPV4.test(t) && t.split('.').every(n => { 34 | const i = parseInt(n, 10) 35 | return i >= 0 && i <= 255 36 | }) 37 | }), 38 | err: 'Invalid IPv4 address' 39 | }, 40 | Keepalive: { 41 | valid: v => !v || (!isNaN(v) && v >= 15 && v <= 120), 42 | min: 15, 43 | max: 120, 44 | err: 'Must be between 15 and 120' 45 | }, 46 | Token: { 47 | valid: t => !t || RX.TOKEN.test(t), 48 | clean: t => t ? t.toLowerCase().replace(RX.HEX, '').slice(0, 64) : '', 49 | err: 'Token must be 64 hex characters' 50 | } 51 | } -------------------------------------------------------------------------------- /Web-version/index_styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-100: #3e5fff; /* NordVPN icon blue */ 3 | --bg-100: #1a1a1a; /* Very dark gray */ 4 | --bg-200: #2d2d2d; /* Slightly lighter dark gray */ 5 | --text-100: #f0f0f0; /* Lighter shade of white */ 6 | } 7 | 8 | body { 9 | background-color: var(--bg-100); 10 | color: var(--text-100); 11 | } 12 | 13 | table { 14 | width: 100%; 15 | border-collapse: collapse; 16 | } 17 | 18 | th, td { 19 | border: 1px solid var(--primary-100); 20 | padding: 8px; 21 | color: var(--text-100); 22 | } 23 | 24 | tr:nth-child(even) { 25 | background-color: var(--bg-200); 26 | } 27 | 28 | th { 29 | padding-top: 12px; 30 | padding-bottom: 12px; 31 | text-align: left; 32 | background-color: var(--primary-100); 33 | color: var(--text-100); 34 | } 35 | 36 | select, button { 37 | background-color: var(--primary-100); 38 | color: var(--text-100); 39 | border: none; 40 | padding: 5px 10px; 41 | text-align: center; 42 | text-decoration: none; 43 | display: inline-block; 44 | font-size: 16px; 45 | margin: 2px; 46 | transition-duration: 0.4s; 47 | cursor: pointer; 48 | border-radius: 12px; 49 | } 50 | 51 | select:hover, button:hover { 52 | background-color: #2c48cc; /* Darker shade of --primary-100 for hover effect */ 53 | } 54 | 55 | div { 56 | display: flex; 57 | flex-wrap: wrap; 58 | align-items: center; 59 | justify-content: flex-start; 60 | } -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/components/Toast.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | future: { 3 | hoverOnlyWhenSupported: true, 4 | }, 5 | content: [ 6 | "./index.html", 7 | "./src/**/*.{vue,js,ts,jsx,tsx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | "vscode-bg": "#1A1A1A", 13 | "vscode-header": "#242424", 14 | "vscode-active": "#0D4A9E", 15 | "vscode-text": "#FFFFFF", 16 | "vscode-accent": "#0090F1", 17 | "nord-text-primary": "#FFFFFF", 18 | "nord-text-secondary": "#E6E6E6", 19 | "nord-text-error": "#FF3B3B", 20 | "nord-button-primary": "#0065C2", 21 | "nord-button-primary-hover": "#0078D4", 22 | "nord-button-secondary": "#595959", 23 | "nord-button-secondary-hover": "#6A6A6A", 24 | "nord-bg-card": "#2A2A2A", 25 | "nord-bg-hover": "#323232", 26 | "nord-bg-active": "#3D3D3D", 27 | "nord-bg-overlay": "rgba(0, 0, 0, 0.75)", 28 | "nord-bg-overlay-light": "rgba(0, 0, 0, 0.5)", 29 | "nord-load-low-bg": "#0B3B1F", 30 | "nord-load-low-text": "#4AFF91", 31 | "nord-load-medium-bg": "#2D4016", 32 | "nord-load-medium-text": "#B8FF84", 33 | "nord-load-warning-bg": "#3D3415", 34 | "nord-load-warning-text": "#FFE484", 35 | "nord-load-high-bg": "#3D1515", 36 | "nord-load-high-text": "#FF9494", 37 | "nord-load-critical-bg": "#4A1515", 38 | "nord-load-critical-text": "#FFBEBE", 39 | "nord-success-bg": "#0B3B1F", 40 | "nord-success-text": "#6EFFAB", 41 | }, 42 | }, 43 | }, 44 | plugins: [], 45 | }; -------------------------------------------------------------------------------- /Obsidian-plugin/src/constants.ts: -------------------------------------------------------------------------------- 1 | // View Constants 2 | export const VIEW_TYPE_NORDVPN = 'nordvpn-config-view'; 3 | 4 | // Cache Settings 5 | export const CACHE_EXPIRY_TIME = 5 * 60 * 1000; // 5 minutes 6 | 7 | // API Related Constants 8 | export const API_ENDPOINTS = { 9 | KEY: '/api/key', 10 | SERVERS: '/api/servers', 11 | CONFIG: '/api/config', 12 | QR_CODE: '/api/config/qr', 13 | DOWNLOAD: '/api/config/download' 14 | } as const; 15 | 16 | // HTTP Status Codes 17 | export const HTTP_STATUS = { 18 | NOT_MODIFIED: 304, 19 | UNAUTHORIZED: 401, 20 | SERVICE_UNAVAILABLE: 503 21 | } as const; 22 | 23 | // Validation Constants 24 | export const TOKEN_REGEX = /^[a-fA-F0-9]{64}$/; 25 | 26 | // Default Plugin Settings 27 | export const DEFAULT_SETTINGS = { 28 | dns: '103.86.96.100', 29 | endpoint_type: 'hostname' as const, 30 | keepalive: 25, 31 | outputFolder: 'nordvpn-configs', 32 | apiUrl: 'https://nord-configs.onrender.com' 33 | } as const; 34 | 35 | // Icon SVG - Modified for Obsidian's ribbon 36 | export const NORDVPN_ICON = ``; -------------------------------------------------------------------------------- /Web-version/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | NordVPN WireGuard Config Generator - Create Your Configs Easily 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
Server NameLoadConfig
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Web-version/key-styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #1a1a1a; 3 | color: #ffffff; 4 | font-family: 'Arial', sans-serif; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | height: 100vh; 9 | margin: 0; 10 | padding: 0; 11 | box-sizing: border-box; 12 | transition: all 0.3s ease; 13 | } 14 | 15 | .container { 16 | transform: scale(1.4); 17 | text-align: center; 18 | max-width: 600px; 19 | padding: 20px; 20 | background-color: #1a1a1a; 21 | border-radius: 10px; 22 | box-shadow: 0 0 10px rgba(0, 0, 0, .5); 23 | transition: all 0.3s ease; 24 | } 25 | 26 | #output, input, button { 27 | margin-top: 20px; 28 | border: 1px solid #3e5fff; 29 | padding: 10px; 30 | word-wrap: break-word; 31 | background-color: #1a1a1a; 32 | color: #ffffff; 33 | transition: all 0.3s ease; 34 | } 35 | 36 | button { 37 | cursor: pointer; 38 | transition: all 0.3s ease; 39 | background-color: #3e5fff; 40 | color: #000; 41 | border: none; 42 | padding: 5px 10px; 43 | text-align: center; 44 | text-decoration: none; 45 | display: inline-block; 46 | font-size: 16px; 47 | margin: 4px 2px; 48 | transition-duration: 0.4s; 49 | cursor: pointer; 50 | border-radius: 12px; 51 | } 52 | 53 | button:hover, input:hover { 54 | background-color: #2c48cc; 55 | } 56 | 57 | input { 58 | width: 80%; 59 | outline: none; 60 | background-color: #1a1a1a; 61 | color: #ffffff; 62 | border: 1px solid #3e5fff; 63 | } 64 | 65 | input:focus { 66 | outline: none; 67 | } 68 | 69 | h1 { 70 | font-size: 2.5em; 71 | text-shadow: 2px 2px 4px #000; 72 | color: #ffffff; 73 | } 74 | 75 | #output { 76 | background-color: #1a1a1a; 77 | color: #ffffff; 78 | } -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import compression from 'vite-plugin-compression2' 4 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' 5 | import path from 'path' 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | cssInjectedByJsPlugin(), 11 | compression({ 12 | algorithm: 'brotliCompress', 13 | exclude: [/\.(br)$/, /\.(gz)$/], 14 | deleteOriginalAssets: false, 15 | }), 16 | ], 17 | resolve: { 18 | alias: { 19 | '@': path.resolve(__dirname, './src'), 20 | }, 21 | }, 22 | build: { 23 | target: 'esnext', 24 | outDir: 'dist', 25 | emptyOutDir: true, 26 | reportCompressedSize: false, 27 | assetsInlineLimit: 4096, 28 | modulePreload: { 29 | polyfill: false, 30 | }, 31 | sourcemap: false, 32 | minify: 'terser', 33 | terserOptions: { 34 | compress: { 35 | drop_console: true, 36 | drop_debugger: true, 37 | pure_funcs: ['console.log', 'console.info', 'console.debug', 'console.warn'], 38 | passes: 3, 39 | ecma: 2020, 40 | unsafe: true, 41 | unsafe_arrows: true, 42 | unsafe_methods: true, 43 | unsafe_proto: true, 44 | booleans_as_integers: true, 45 | }, 46 | mangle: { 47 | toplevel: true, 48 | }, 49 | format: { 50 | comments: false, 51 | ecma: 2020, 52 | }, 53 | }, 54 | rollupOptions: { 55 | output: { 56 | manualChunks: (id) => { 57 | if (id.includes('node_modules')) { 58 | return 'vendor' 59 | } 60 | }, 61 | }, 62 | }, 63 | }, 64 | server: { 65 | port: 8080, 66 | open: true, 67 | proxy: { 68 | '/api': { 69 | target: 'http://localhost:3000', 70 | changeOrigin: true, 71 | }, 72 | }, 73 | }, 74 | }) -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/internal/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ConfigRequest struct { 4 | Token string `json:"token"` 5 | Country string `json:"country"` 6 | City string `json:"city"` 7 | Name string `json:"name"` 8 | PrivateKey string `json:"privateKey"` 9 | DNS string `json:"dns"` 10 | Endpoint string `json:"endpoint"` 11 | KeepAlive *int `json:"keepalive"` 12 | } 13 | 14 | type BatchConfigReq struct { 15 | Token string `json:"token"` 16 | PrivateKey string `json:"privateKey"` 17 | DNS string `json:"dns"` 18 | Endpoint string `json:"endpoint"` 19 | KeepAlive *int `json:"keepalive"` 20 | Country string `json:"country"` 21 | City string `json:"city"` 22 | } 23 | 24 | type ValidatedConfig struct { 25 | Name string 26 | PrivateKey string 27 | DNS string 28 | UseStation bool 29 | KeepAlive int 30 | } 31 | 32 | type ServerLoc struct { 33 | Country struct { 34 | Name string `json:"name"` 35 | Code string `json:"code"` 36 | City struct { 37 | Name string `json:"name"` 38 | } `json:"city"` 39 | } `json:"country"` 40 | } 41 | 42 | type RawServer struct { 43 | Name string `json:"name"` 44 | Station string `json:"station"` 45 | Hostname string `json:"hostname"` 46 | Load int `json:"load"` 47 | Locations []ServerLoc `json:"locations"` 48 | Technologies []struct { 49 | Metadata []struct { 50 | Name string `json:"name"` 51 | Value string `json:"value"` 52 | } `json:"metadata"` 53 | } `json:"technologies"` 54 | } 55 | 56 | type ProcessedServer struct { 57 | Name string 58 | Station string 59 | Hostname string 60 | Country string 61 | City string 62 | Code string 63 | KeyID int 64 | } 65 | 66 | type ServerPayload struct { 67 | Headers []string `json:"h"` 68 | List map[string]map[string][][]interface{} `json:"l"` 69 | } 70 | 71 | type Asset struct { 72 | Content []byte 73 | Brotli []byte 74 | Mime string 75 | Etag string 76 | } 77 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/services/apiService.js: -------------------------------------------------------------------------------- 1 | const BASE = '/api' 2 | const TIMEOUT = 60000 3 | const MIME = { json: 'application/json', wg: 'application/x-wireguard-config', img: 'image/', zip: 'application/zip', bin: 'application/octet-stream' } 4 | 5 | async function req(end, opt = {}) { 6 | const c = new AbortController() 7 | const id = setTimeout(() => c.abort(), TIMEOUT) 8 | try { 9 | const r = await fetch(`${BASE}${end}`, { 10 | ...opt, 11 | headers: { 'Content-Type': MIME.json, ...opt.headers }, 12 | signal: c.signal 13 | }) 14 | if (!r.ok) { 15 | const e = new Error(`HTTP ${r.status}`) 16 | e.status = r.status 17 | throw e 18 | } 19 | const t = r.headers.get('content-type') || '' 20 | if (t.includes(MIME.wg) || t.startsWith(MIME.img) || t.includes(MIME.zip) || t.includes(MIME.bin)) return r 21 | return t.includes(MIME.json) ? r.json() : r.text() 22 | } catch (e) { 23 | throw e.name === 'AbortError' ? new Error('Request timeout') : e 24 | } finally { 25 | clearTimeout(id) 26 | } 27 | } 28 | 29 | export const api = { 30 | genKey: token => req('/key', { method: 'POST', body: JSON.stringify({ token }) }), 31 | genConfig: data => req('/config', { method: 'POST', body: JSON.stringify(data) }), 32 | dlConfig: async data => { 33 | const r = await req('/config/download', { 34 | method: 'POST', 35 | body: JSON.stringify(data), 36 | headers: { 'Accept': MIME.wg } 37 | }) 38 | const m = /filename="([^"]+)"/.exec(r.headers.get('content-disposition') || '') 39 | return { blob: await r.blob(), name: m?.[1] || null } 40 | }, 41 | dlBatch: async data => { 42 | const r = await req('/config/batch', { 43 | method: 'POST', 44 | body: JSON.stringify(data), 45 | headers: { 'Accept': MIME.bin } 46 | }) 47 | const m = /filename="([^"]+)"/.exec(r.headers.get('content-disposition') || '') 48 | let name = m?.[1] || 'NordVPN_Configs.zip' 49 | if (name.endsWith('.nord')) name = name.replace('.nord', '.zip') 50 | return { blob: await r.blob(), name } 51 | }, 52 | genQR: async data => { 53 | const r = await req('/config/qr', { 54 | method: 'POST', 55 | body: JSON.stringify(data), 56 | headers: { 'Accept': MIME.img } 57 | }) 58 | return r.blob() 59 | } 60 | } -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/README.md: -------------------------------------------------------------------------------- 1 | # NordGen Backend 2 | 3 | A minimalist, high-performance backend service for generating NordVPN WireGuard configurations. Built on the **Go** programming language and the **Fiber** framework, this service handles server data caching, credential exchange, and configuration generation with extreme efficiency. 4 | 5 | ## Overview 6 | 7 | This application serves as the API layer for the NordGen project. It interfaces directly with NordVPN's infrastructure to retrieve server lists and exchange authentication tokens for WireGuard private keys. It provides endpoints to generate configuration files in text, file, or QR code formats. 8 | 9 | ## Prerequisites 10 | 11 | - Go 1.25+ (Recommended) 12 | 13 | ## Installation 14 | 15 | Clone the repository and download the dependencies: 16 | 17 | ```bash 18 | git clone https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator 19 | cd NordVPN-WireGuard-Config-Generator 20 | go mod download 21 | ``` 22 | 23 | ## Development 24 | 25 | Start the server directly using the Go toolchain: 26 | 27 | ```bash 28 | go run main.go 29 | ``` 30 | 31 | The server listens on port `3000` by default. 32 | 33 | ## Production 34 | 35 | To build and run the optimized production binary: 36 | 37 | ```bash 38 | # Build the binary with size optimizations (strip debug symbols) 39 | CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o server main.go 40 | 41 | # Run the binary 42 | ./server 43 | ``` 44 | 45 | ## Architecture 46 | 47 | - **Core Store**: Maintains a thread-safe in-memory cache of NordVPN servers, refreshed every 5 minutes. It also handles static asset serving with pre-compressed Brotli support and ETag caching. 48 | - **Validation**: Strict input validation ensures all data sent to upstream APIs or used in configuration generation is sanitized. 49 | - **Performance**: Utilizes **Fiber's** zero-allocation routing and Go's native concurrency model to handle high throughput with minimal resource usage. 50 | 51 | ## Static Assets 52 | 53 | The server looks for a `./public` directory to serve static frontend files. If an `index.html` is present, it is served for the root path and any unknown routes (SPA fallback), with the server data injected directly into the HTML to prevent an initial round-trip fetch. 54 | 55 | ## API Documentation 56 | 57 | For detailed endpoint specifications, request/response formats, and validation rules, please refer to the [API.md](./API.md). -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/components/ServerCard.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/composables/useConfig.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { api } from '@/services/apiService' 3 | import { storage } from '@/services/storageService' 4 | import { Validators, sanitizeName } from '@/utils/utils' 5 | 6 | const KEY = 'wg_gen_settings' 7 | const DEF = { dns: '103.86.96.100', endpoint: 'hostname', keepalive: 25 } 8 | 9 | export function useConfig() { 10 | const privKey = ref('') 11 | const settings = ref({ ...DEF }) 12 | 13 | const load = () => { 14 | const s = storage.get(KEY) 15 | if (s && Validators.DNS.valid(s.dns) && Validators.Keepalive.valid(s.keepalive)) { 16 | settings.value = { 17 | dns: s.dns ?? DEF.dns, 18 | endpoint: s.endpoint ?? DEF.endpoint, 19 | keepalive: s.keepalive ?? DEF.keepalive 20 | } 21 | } 22 | } 23 | 24 | const save = s => { 25 | const next = { 26 | dns: s.dns ?? settings.value.dns, 27 | endpoint: s.endpoint ?? settings.value.endpoint, 28 | keepalive: s.keepalive ?? settings.value.keepalive 29 | } 30 | 31 | if (Validators.DNS.valid(next.dns) && Validators.Keepalive.valid(next.keepalive)) { 32 | storage.set(KEY, next) 33 | settings.value = next 34 | } 35 | } 36 | 37 | const make = s => ({ 38 | country: sanitizeName(s.country), 39 | city: sanitizeName(s.city), 40 | name: s.name, 41 | privateKey: privKey.value, 42 | dns: settings.value.dns, 43 | endpoint: settings.value.endpoint, 44 | keepalive: settings.value.keepalive 45 | }) 46 | 47 | const saveBlob = (blob, name) => { 48 | const url = URL.createObjectURL(blob) 49 | const a = document.createElement('a') 50 | a.href = url 51 | a.download = name 52 | a.click() 53 | URL.revokeObjectURL(url) 54 | } 55 | 56 | const dl = async s => { 57 | const { blob, name } = await api.dlConfig(make(s)) 58 | saveBlob(blob, name || `${s.name}.conf`) 59 | } 60 | 61 | const dlBatch = async (filters = {}) => { 62 | const body = { 63 | privateKey: privKey.value, 64 | dns: settings.value.dns, 65 | endpoint: settings.value.endpoint, 66 | keepalive: settings.value.keepalive, 67 | country: filters.country || '', 68 | city: filters.city || '' 69 | } 70 | const { blob, name } = await api.dlBatch(body) 71 | saveBlob(blob, name) 72 | } 73 | 74 | return { 75 | privKey, 76 | settings, 77 | defaults: DEF, 78 | load, 79 | save, 80 | setKey: k => { if (Validators.Key.valid(k)) privKey.value = k; else throw new Error(Validators.Key.err) }, 81 | dl, 82 | dlBatch, 83 | copy: async s => navigator.clipboard.writeText(await api.genConfig(make(s))), 84 | make 85 | } 86 | } -------------------------------------------------------------------------------- /Obsidian-plugin/README.md: -------------------------------------------------------------------------------- 1 | # NordVPN Config Generator - Obsidian Plugin 2 | 3 | A component of the [NordVPN WireGuard Config Generator](https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator) suite. 4 | 5 | ## Overview 6 | 7 | This Obsidian plugin generates WireGuard configurations for NordVPN servers directly within your vault. It uses a custom API layer built on top of NordVPN's infrastructure for simplified server management and configuration generation. 8 | 9 | ## Features 10 | 11 | - Generate WireGuard configurations for any NordVPN server 12 | - Select servers by country and city 13 | - View real-time server load information 14 | - Flexible private key management 15 | - Custom configuration viewer 16 | - Automatic configuration file organization 17 | 18 | ## Requirements 19 | 20 | - Obsidian v1.0.0 or higher 21 | - Active NordVPN subscription 22 | - NordVPN authentication token (optional, only needed for key generation) 23 | 24 | ## Installation 25 | 26 | 1. Download the latest release (.zip file) from the releases section 27 | 2. Extract the zip file in your vault's `.obsidian/plugins` folder 28 | 3. Enable the plugin in Obsidian Settings > Community Plugins 29 | 30 | ## Development 31 | 32 | ### Prerequisites 33 | - Node.js 34 | - npm/yarn 35 | - TypeScript knowledge 36 | 37 | ### Setup 38 | 1. Clone the repository 39 | 2. Run `npm install` to install dependencies 40 | 3. Create a test vault for development 41 | 42 | ### Build Commands 43 | - `npm run dev` - Start development build with watch mode 44 | - `npm run build` - Create production build 45 | 46 | The plugin uses esbuild for fast builds and TypeScript for type safety. Development builds include source maps for easier debugging. 47 | 48 | ## Usage 49 | 50 | 1. Open the NordVPN Config Generator from the ribbon icon 51 | 2. Choose your private key method: 52 | - Generate new key using your NordVPN token 53 | - Input existing private key 54 | - Generate config without private key 55 | 3. Select your desired server location 56 | 4. Generate and save the configuration 57 | 58 | ## Configuration 59 | 60 | Access plugin settings through Obsidian Settings > NordVPN Config Generator: 61 | 62 | - DNS Servers: Set multiple DNS servers for configurations 63 | - Endpoint Type: Choose between hostname or station format 64 | - Keepalive: Set WireGuard keepalive interval 65 | - Output Folder: Specify where configurations are saved 66 | 67 | ## Security 68 | 69 | - Private keys are stored in encrypted format using a timestamp-based key derivation 70 | - Authentication tokens are used only for key generation and never stored 71 | - All sensitive data is encrypted before saving to disk 72 | - No sensitive data is transmitted to external servers except through our secure API layer 73 | 74 | ## Support 75 | 76 | For issues and feature requests, please use the [issue tracker](https://github.com/mustafachyi/NordVPN-WireGuard-Config-Generator). 77 | 78 | ## License 79 | 80 | GPL-3.0 81 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/API.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | Base URL: `http://localhost:3000` 4 | 5 | ## Response Format 6 | 7 | - **Success**: Returns standard JSON with a `200 OK` status code. 8 | - **Error**: Returns a JSON object `{"error": "message"}` with an appropriate HTTP status code (400, 401, 404, 500, 503). 9 | 10 | ## Endpoints 11 | 12 | ### 1. Get Server List 13 | 14 | Retrieves the cached list of available WireGuard-compatible NordVPN servers. 15 | 16 | **Endpoint:** `GET /api/servers` 17 | 18 | **Headers:** 19 | - `If-None-Match`: (Optional) The ETag from a previous request. 20 | 21 | **Response:** 22 | - **200 OK**: Returns the server data. 23 | - **304 Not Modified**: If the provided ETag matches the current data version. 24 | - **503 Service Unavailable**: If the server cache is initializing. 25 | 26 | **Response Body Structure:** 27 | The data is optimized for network payload size. 28 | - `h`: Array of column headers (e.g., `["name", "load", "station"]`). 29 | - `l`: Nested object structure: `Country -> City -> Array of Servers`. 30 | - Each server is an array matching the headers in `h`. 31 | 32 | ### 2. Exchange Token 33 | 34 | Exchanges a NordVPN access token for a WireGuard private key. 35 | 36 | **Endpoint:** `POST /api/key` 37 | 38 | **Request Body:** 39 | ```json 40 | { 41 | "token": "string" 42 | } 43 | ``` 44 | 45 | **Validation:** 46 | - `token`: Must be a 64-character hexadecimal string. 47 | 48 | **Response:** 49 | ```json 50 | { 51 | "key": "string" // The NordLynx private key 52 | } 53 | ``` 54 | 55 | ### 3. Generate Configuration 56 | 57 | Generates a WireGuard configuration based on the selected server and user credentials. 58 | 59 | **Endpoints:** 60 | - `POST /api/config` - Returns configuration as plain text. 61 | - `POST /api/config/download` - Returns configuration as a downloadable `.conf` file. 62 | - `POST /api/config/qr` - Returns configuration as a PNG QR code image. 63 | 64 | **Request Body:** 65 | ```json 66 | { 67 | "country": "string", // Required 68 | "city": "string", // Required 69 | "name": "string", // Required (Server name, e.g., "us1234") 70 | "privateKey": "string", // Optional (If not provided, config will be invalid) 71 | "dns": "string", // Optional (Comma separated IPs, default: 103.86.96.100) 72 | "endpoint": "string", // Optional ("hostname" or "station", default: hostname) 73 | "keepalive": number // Optional (15-120, default: 25) 74 | } 75 | ``` 76 | 77 | **Validation Rules:** 78 | - `privateKey`: Must be a valid Base64 WireGuard key (43 characters ending in `=`). 79 | - `dns`: Must be valid IPv4 addresses. 80 | - `keepalive`: Must be an integer between 15 and 120. 81 | 82 | **Response Headers:** 83 | - **Text**: `Content-Type: text/plain` 84 | - **File**: `Content-Type: application/x-wireguard-config`, `Content-Disposition: attachment` 85 | - **QR**: `Content-Type: image/png` 86 | 87 | ## Rate Limiting 88 | 89 | API endpoints are rate-limited to **100 requests per 1 minute per IP address**. -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= 2 | github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 3 | github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= 4 | github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= 5 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 6 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 7 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 8 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 9 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 10 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 11 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 12 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 13 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 14 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 15 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 16 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= 17 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 18 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 19 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 20 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 21 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 22 | github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= 23 | github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= 24 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 25 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 26 | github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= 27 | github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= 28 | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 29 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 30 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 31 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 32 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 35 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 36 | -------------------------------------------------------------------------------- /nordgen/src/nord_config_generator/ui.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | from rich.panel import Panel 3 | from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn 4 | from rich.theme import Theme 5 | from rich.table import Table 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | import os 9 | 10 | if TYPE_CHECKING: 11 | from .main import UserPreferences, GenerationStats 12 | 13 | class ConsoleManager: 14 | def __init__(self): 15 | custom_theme = Theme({ 16 | "info": "cyan", 17 | "success": "bold green", 18 | "warning": "yellow", 19 | "error": "bold red", 20 | "title": "bold magenta", 21 | "path": "underline bright_blue" 22 | }) 23 | self.console = Console(theme=custom_theme) 24 | 25 | def clear(self): 26 | os.system('cls' if os.name == 'nt' else 'clear') 27 | 28 | def print_title(self): 29 | self.console.print(Panel("[title]NordVPN Configuration Generator[/title]", expand=False, border_style="info")) 30 | 31 | def get_user_input(self, prompt: str, is_secret: bool = False) -> str: 32 | return self.console.input(f"[info]{prompt}[/info]", password=is_secret).strip() 33 | 34 | def get_preferences(self, defaults: "UserPreferences") -> dict: 35 | self.console.print("\n[info]Configuration Options (press Enter to use defaults)[/info]") 36 | dns = self.get_user_input(f"Enter DNS server IP (default: {defaults.dns}): ") 37 | endpoint_type = self.get_user_input("Use IP instead of hostname for endpoints? (y/N): ") 38 | keepalive = self.get_user_input(f"Enter PersistentKeepalive value (default: {defaults.persistent_keepalive}): ") 39 | return {"dns": dns, "endpoint_type": endpoint_type, "keepalive": keepalive} 40 | 41 | def print_message(self, style: str, message: str): 42 | self.console.print(f"[{style}]{message}[/{style}]") 43 | 44 | def create_progress_bar(self, transient: bool = True) -> Progress: 45 | return Progress( 46 | SpinnerColumn(), 47 | TextColumn("[progress.description]{task.description}"), 48 | BarColumn(), 49 | TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), 50 | TimeElapsedColumn(), 51 | console=self.console, 52 | transient=transient 53 | ) 54 | 55 | def display_key(self, key: str): 56 | key_panel = Panel(key, title="NordLynx Private Key", border_style="success", expand=False) 57 | self.console.print(key_panel) 58 | 59 | def display_summary(self, output_dir: Path, stats: "GenerationStats", elapsed_time: float): 60 | summary_table = Table.grid(padding=(0, 2)) 61 | summary_table.add_column(style="info") 62 | summary_table.add_column() 63 | summary_table.add_row("Output Directory:", f"[path]{output_dir}[/path]") 64 | summary_table.add_row("Standard Configs:", f"{stats.total_configs}") 65 | summary_table.add_row("Optimized Configs:", f"{stats.best_configs}") 66 | summary_table.add_row("Time Taken:", f"{elapsed_time:.2f} seconds") 67 | 68 | self.console.print(Panel( 69 | summary_table, 70 | title="[success]Generation Complete[/success]", 71 | border_style="success", 72 | expand=False 73 | )) -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/components/KeyGenerator.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/composables/useServers.js: -------------------------------------------------------------------------------- 1 | import { shallowRef, computed, watch, markRaw } from 'vue' 2 | import { formatName } from '@/utils/utils' 3 | 4 | const INC = 24 5 | 6 | export function useServers() { 7 | const all = shallowRef([]) 8 | const loading = shallowRef(false) 9 | const sortKey = shallowRef('name') 10 | const sortOrd = shallowRef('asc') 11 | const fCountry = shallowRef('') 12 | const fCity = shallowRef('') 13 | const limit = shallowRef(INC) 14 | 15 | const countries = shallowRef([]) 16 | const cityMap = shallowRef({}) 17 | 18 | const filtered = computed(() => { 19 | const c = fCountry.value 20 | const t = fCity.value 21 | let list = all.value 22 | 23 | if (c) list = list.filter(s => s.country === c) 24 | if (t) list = list.filter(s => s.city === t) 25 | 26 | const k = sortKey.value 27 | const m = sortOrd.value === 'asc' ? 1 : -1 28 | 29 | return [...list].sort((a, b) => { 30 | if (k === 'load') { 31 | const d = a.load - b.load 32 | if (d !== 0) return d * m 33 | } 34 | return (a.dName > b.dName ? 1 : -1) * m 35 | }) 36 | }) 37 | 38 | const visible = computed(() => filtered.value.slice(0, limit.value)) 39 | const total = computed(() => filtered.value.length) 40 | const currentCities = computed(() => cityMap.value[fCountry.value] || []) 41 | 42 | const reset = () => { 43 | limit.value = INC 44 | window.scrollTo(0, 0) 45 | } 46 | 47 | watch(fCountry, () => { 48 | const l = cityMap.value[fCountry.value] || [] 49 | fCity.value = l.length === 1 ? l[0].id : '' 50 | reset() 51 | }) 52 | 53 | watch([fCity, sortKey, sortOrd], reset) 54 | 55 | const init = async () => { 56 | loading.value = true 57 | try { 58 | const el = document.getElementById('server-data') 59 | if (!el?.textContent) return 60 | 61 | const { h, l } = JSON.parse(el.textContent) 62 | const idx = Object.fromEntries(h.map((k, i) => [k, i])) 63 | if (!['name', 'load', 'station'].every(k => k in idx)) throw new Error('Invalid data') 64 | 65 | const list = [] 66 | const cSet = new Set() 67 | const cMap = {} 68 | const fmtCache = new Map() 69 | 70 | const getFmt = s => { 71 | if (fmtCache.has(s)) return fmtCache.get(s) 72 | const v = formatName(s) 73 | fmtCache.set(s, v) 74 | return v 75 | } 76 | 77 | for (const [cn, cities] of Object.entries(l)) { 78 | cSet.add(cn) 79 | const cityList = [] 80 | const dCountry = getFmt(cn) 81 | 82 | for (const [ci, servers] of Object.entries(cities)) { 83 | cityList.push(ci) 84 | const dCity = getFmt(ci) 85 | 86 | for (const t of servers) { 87 | list.push(markRaw({ 88 | name: t[idx.name], 89 | load: t[idx.load], 90 | station: t[idx.station], 91 | ip: t[idx.station], 92 | country: cn, 93 | city: ci, 94 | dName: formatName(t[idx.name]), 95 | dCountry, 96 | dCity 97 | })) 98 | } 99 | } 100 | cMap[cn] = cityList.sort().map(c => ({ id: c, name: getFmt(c) })) 101 | } 102 | 103 | all.value = list 104 | countries.value = [...cSet].sort().map(c => ({ id: c, name: getFmt(c) })) 105 | cityMap.value = cMap 106 | } catch (e) { 107 | console.error(e) 108 | } finally { 109 | loading.value = false 110 | } 111 | } 112 | 113 | return { 114 | visible, 115 | loading, 116 | sortKey, 117 | sortOrd, 118 | fCountry, 119 | fCity, 120 | countries, 121 | cities: currentCities, 122 | total, 123 | loadMore: () => { if (!loading.value && limit.value < total.value) limit.value += INC }, 124 | toggleSort: k => { 125 | if (sortKey.value === k) sortOrd.value = sortOrd.value === 'asc' ? 'desc' : 'asc' 126 | else { sortKey.value = k; sortOrd.value = 'asc' } 127 | }, 128 | init 129 | } 130 | } -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/utils/icons.js: -------------------------------------------------------------------------------- 1 | export const icons = { 2 | close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z', 3 | menu: 'M0 9v2h50V9Zm0 15v2h50v-2Zm0 15v2h50v-2Z', 4 | settings: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.63-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6', 5 | arrowUp: 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z', 6 | eye: 'M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z', 7 | eyeOff: 'M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z', 8 | check: 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z', 9 | key: 'M12.65 10A5.99 5.99 0 0 0 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6a5.99 5.99 0 0 0 5.65-4H17v4h4v-4h2v-4zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2', 10 | downloadConfig: 'M12.5 4C10.032 4 8 6.032 8 8.5v31c0 2.468 2.032 4.5 4.5 4.5h23c2.468 0 4.5-2.032 4.5-4.5v-21a1.5 1.5 0 0 0-.44-1.06l-.015-.016L26.56 4.439A1.5 1.5 0 0 0 25.5 4zm0 3H24v8.5c0 2.468 2.032 4.5 4.5 4.5H37v19.5c0 .846-.654 1.5-1.5 1.5h-23c-.846 0-1.5-.654-1.5-1.5v-31c0-.846.654-1.5 1.5-1.5M27 9.121 34.879 17H28.5c-.846 0-1.5-.654-1.5-1.5zM23.977 21.98A1.5 1.5 0 0 0 22.5 23.5v6.379l-1.44-1.44a1.5 1.5 0 1 0-2.12 2.122l4 4a1.5 1.5 0 0 0 1.08.439 1.5 1.5 0 0 0 1.04-.44l4-4a1.5 1.5 0 1 0-2.122-2.12L25.5 29.878V23.5a1.5 1.5 0 0 0-1.523-1.521M24.02 35H17.5a1.5 1.5 0 1 0 0 3h13a1.5 1.5 0 1 0 0-3z', 11 | copyConfig: 'M4 2a2 2 0 0 0-2 2v14h2V4h14V2zm4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2zm0 2h12v12H8z', 12 | showQr: 'M4 3a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm7 0v2h2V3zm5 0a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 5h2v2H5zm12 0h2v2h-2zm-6 2v2h2V7zm-8 4v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zm2 2v2h2v-2zm2 0h2v-2h-2zm2 0v2h2v-2zm2 0h2v-2h-2zm0 2v2h2v-2zm0 2h-2v2h2zm-2 0h-2v2h2zm-2 0v-2h-2v2zm-2 0h-2v2h2zm0-2v-2h-2v2zm2 0h2v-2h-2zM4 15a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1zm1 2h2v2H5z', 13 | error: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z', 14 | sortAsc: 'M7 14l5-5 5 5z', 15 | sortDesc: 'M7 10l5 5 5-5z', 16 | github: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a47 47 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0', 17 | externalLink: 'M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z', 18 | archive: 'M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM6.24 5h11.52l.81.97H5.44l.8-.97zM5 19V8h14v11H5zm8.45-9h-2.9v3H8l4 4 4-4h-2.55z' 19 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NordVPN WireGuard Configuration Generator 2 | 3 | A command-line tool for generating optimized NordVPN WireGuard configurations. 4 | 5 | ## Project Philosophy: A Focus on Quality 6 | 7 | This project has been fundamentally refocused. Previously, multiple versions existed across several programming languages. This approach divided development effort and resulted in inconsistent quality. 8 | 9 | The new directive is singular: to provide one exceptionally engineered tool that is robust, maintainable, and correct. 10 | 11 | To this end, all previous language implementations have been archived. Development is now concentrated on two platforms: 12 | 13 | 1. **This Command-Line Tool:** A complete rewrite in Python, packaged for professional use. 14 | 2. **A Web Interface:** For users who require a graphical frontend. 15 | 16 | This consolidated effort ensures a higher standard of quality and a more reliable end-product. 17 | 18 | ## Core Capabilities 19 | 20 | * **Package Distribution:** The tool is a proper command-line application, installable via PyPI. This eliminates manual dependency management. 21 | * **Performance:** Asynchronous architecture processes the entire NordVPN server list in seconds. 22 | * **Optimization:** Intelligently sorts servers by current load and geographic proximity to the user, generating configurations for the most performant connections. 23 | * **Structured Output:** Automatically creates a clean directory structure containing standard configurations, a `best_configs` folder for optimal servers per location, and a `servers.json` file with detailed metadata for analysis. 24 | * **Interactive and Non-Interactive:** A guided rich-CLI for interactive use. The core logic is structured to be scriptable. 25 | 26 | ## Installation 27 | 28 | Prerequisites: Python 3.9+ 29 | 30 | Install the package using `pip`: 31 | 32 | ```bash 33 | pip install nord-config-generator 34 | ``` 35 | 36 | ## Running with Docker 37 | 38 | For a dependency-free execution, the application can be run using Docker. This method does not require a local Python installation. 39 | 40 | ### Method 1: Docker Compose (Recommended) 41 | 42 | 1. Create a file named `docker-compose.yml` in an empty directory with the following content: 43 | 44 | ```yaml 45 | services: 46 | nordgen: 47 | image: mustafachyi/nordgen:latest 48 | stdin_open: true 49 | tty: true 50 | volumes: 51 | - ./generated_configs:/data 52 | ``` 53 | 54 | 2. Run the application from the same directory: 55 | 56 | ```sh 57 | docker-compose run --rm nordgen 58 | ``` 59 | Generated files will be saved to a new `generated_configs` directory. 60 | 61 | ### Method 2: Docker Run 62 | 63 | Alternatively, use the `docker run` command directly without creating a configuration file. 64 | 65 | * **Linux / macOS:** 66 | ```sh 67 | docker run -it --rm -v "$(pwd)/generated_configs:/data" mustafachyi/nordgen:latest 68 | ``` 69 | * **Windows (PowerShell):** 70 | ```sh 71 | docker run -it --rm -v "${PWD}/generated_configs:/data" mustafachyi/nordgen:latest 72 | ``` 73 | * **Windows (Command Prompt):** 74 | ```sh 75 | docker run -it --rm -v "%cd%/generated_configs:/data" mustafachyi/nordgen:latest 76 | ``` 77 | 78 | ## Usage 79 | 80 | ### Generate Configurations (Default Action) 81 | 82 | Execute the application without any arguments. This is the primary function. 83 | 84 | ```bash 85 | nordgen 86 | ``` 87 | 88 | The application will prompt for the required access token and configuration preferences. 89 | 90 | ### Retrieve Private Key 91 | 92 | To retrieve and display your NordLynx private key without generating configurations, use the `get-key` command: 93 | 94 | ```bash 95 | nordgen get-key 96 | ``` 97 | 98 | ## Web Version 99 | 100 | A graphical alternative is available for direct use in a web browser. 101 | 102 | * **Current Version:** [https://nord-configs.selfhoster.nl/](https://nord-configs.selfhoster.nl/) 103 | * **Legacy Version:** [https://wg-nord.pages.dev/](https://wg-nord.pages.dev/) 104 | 105 | ## Support 106 | 107 | Project visibility and continued development are supported by two actions: 108 | 109 | 1. **Star the Repository:** Starring the project on GitHub increases its visibility. 110 | 2. **NordVPN Referral:** Using the referral link for new subscriptions provides support at no additional cost. Link: [https://ref.nordvpn.com/MXIVDoJGpKT](https://ref.nordvpn.com/MXIVDoJGpKT) 111 | 112 | ## License 113 | 114 | This project is distributed under the GNU General Public License v3.0. See the `LICENSE` file for full details. 115 | -------------------------------------------------------------------------------- /Obsidian-plugin/src/api.service.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'; 3 | import { API_ENDPOINTS, HTTP_STATUS } from './constants'; 4 | import { normalizeServerName, validateData, generateKey, buildPath } from './utils'; 5 | 6 | // Request Types 7 | export interface ConfigRequest { 8 | country: string; 9 | city: string; 10 | name: string; 11 | privateKey?: string; 12 | dns: string; 13 | endpoint: 'hostname' | 'station'; 14 | keepalive: number; 15 | } 16 | 17 | // API Response Types 18 | interface ApiErrorResponse { 19 | error?: string; 20 | } 21 | 22 | // Custom Error Types 23 | class ApiError extends Error { 24 | constructor(message: string, public status?: number) { 25 | super(message); 26 | this.name = 'ApiError'; 27 | } 28 | } 29 | 30 | /** 31 | * Configuration for secure data handling 32 | */ 33 | const secureConfig = { 34 | version: '2.1.0', 35 | encoding: 'base64' as BufferEncoding, 36 | cipher: 'aes-256-gcm' as const, 37 | timeout: 300000, 38 | tagLength: 16, 39 | port: 51820, 40 | maxLength: 85 41 | }; 42 | 43 | /** 44 | * Generates random initialization vector 45 | */ 46 | function generateIV(): Buffer { 47 | return randomBytes(16); 48 | } 49 | 50 | /** 51 | * Formats buffer to string 52 | */ 53 | function formatBuffer(data: Buffer): string { 54 | return data.toString(secureConfig.encoding); 55 | } 56 | 57 | /** 58 | * Parses string to buffer 59 | */ 60 | function parseBuffer(data: string): Buffer { 61 | return Buffer.from(data, secureConfig.encoding); 62 | } 63 | 64 | /** 65 | * Encrypts data with provided key 66 | */ 67 | function encryptData(input: string, key: string): { iv: string, data: string } { 68 | const iv = generateIV(); 69 | const cipher = createCipheriv(secureConfig.cipher, parseBuffer(key), iv); 70 | const encrypted = Buffer.concat([cipher.update(Buffer.from(input,'utf8')), cipher.final()]); 71 | const tag = (cipher as any).getAuthTag(); 72 | return { iv: formatBuffer(iv), data: formatBuffer(Buffer.concat([encrypted, tag])) }; 73 | } 74 | 75 | /** 76 | * Decrypts data with provided key 77 | */ 78 | function decryptData(iv: string, data: string, key: string): string { 79 | const ivBuffer = parseBuffer(iv); 80 | const dataBuffer = parseBuffer(data); 81 | const tag = dataBuffer.slice(-16); 82 | const encrypted = dataBuffer.slice(0, -16); 83 | const decipher = createDecipheriv(secureConfig.cipher, parseBuffer(key), ivBuffer); 84 | (decipher as any).setAuthTag(tag); 85 | return decipher.update(encrypted) + decipher.final('utf8'); 86 | } 87 | 88 | export class ApiService { 89 | constructor(private apiUrl: string) {} 90 | 91 | async validateToken(token: string): Promise { 92 | try { 93 | const response = await axios.post(`${this.apiUrl}${API_ENDPOINTS.KEY}`, { token }); 94 | return response.data.key; 95 | } catch (error) { 96 | return this.handleApiError(error as AxiosError, 'Failed to validate token'); 97 | } 98 | } 99 | 100 | async getServers(etag?: string): Promise<{ data: any; etag?: string }> { 101 | try { 102 | const headers: Record = {}; 103 | if (etag) { 104 | headers['If-None-Match'] = etag; 105 | } 106 | 107 | const response = await axios.get(`${this.apiUrl}${API_ENDPOINTS.SERVERS}`, { headers }); 108 | return { 109 | data: response.data, 110 | etag: response.headers['etag'] 111 | }; 112 | } catch (error) { 113 | const axiosError = error as AxiosError; 114 | if (axiosError.response?.status === HTTP_STATUS.NOT_MODIFIED) { 115 | return { data: null }; 116 | } 117 | return this.handleApiError(axiosError, 'Failed to fetch server list'); 118 | } 119 | } 120 | 121 | async generateConfig(config: ConfigRequest): Promise { 122 | try { 123 | const response = await axios.post( 124 | `${this.apiUrl}${API_ENDPOINTS.CONFIG}`, 125 | config, 126 | { headers: { 'Accept': 'text/plain' } } 127 | ); 128 | return response.data; 129 | } catch (error) { 130 | return this.handleApiError(error as AxiosError, 'Failed to generate configuration'); 131 | } 132 | } 133 | 134 | async generateQRCode(config: ConfigRequest): Promise { 135 | try { 136 | const response = await axios.post( 137 | `${this.apiUrl}${API_ENDPOINTS.QR_CODE}`, 138 | config, 139 | { responseType: 'arraybuffer' } 140 | ); 141 | return new Blob([response.data], { type: 'image/webp' }); 142 | } catch (error) { 143 | return this.handleApiError(error as AxiosError, 'Failed to generate QR code'); 144 | } 145 | } 146 | 147 | async downloadConfig(config: ConfigRequest): Promise { 148 | try { 149 | const response = await axios.post( 150 | `${this.apiUrl}${API_ENDPOINTS.DOWNLOAD}`, 151 | config, 152 | { responseType: 'blob' } 153 | ); 154 | return new Blob([response.data], { type: 'application/x-wireguard-config' }); 155 | } catch (error) { 156 | return this.handleApiError(error as AxiosError, 'Failed to download configuration'); 157 | } 158 | } 159 | 160 | /** 161 | * Encrypts data with provided key 162 | */ 163 | encryptData(input: string, key: string): { iv: string, data: string } { 164 | return encryptData(input, key); 165 | } 166 | 167 | /** 168 | * Decrypts data with provided key 169 | */ 170 | decryptData(iv: string, data: string, key: string): string { 171 | return decryptData(iv, data, key); 172 | } 173 | 174 | private handleApiError(error: AxiosError, defaultMessage: string): never { 175 | const status = error.response?.status; 176 | const message = error.response?.data?.error || error.message; 177 | 178 | if (status === HTTP_STATUS.UNAUTHORIZED) { 179 | throw new ApiError('Invalid or unauthorized token.', status); 180 | } else if (status === HTTP_STATUS.SERVICE_UNAVAILABLE) { 181 | throw new ApiError('NordVPN API is currently unavailable.', status); 182 | } 183 | 184 | throw new ApiError(`${defaultMessage}: ${message}`, status); 185 | } 186 | } -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/components/ConfigCustomizer.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | -------------------------------------------------------------------------------- /Obsidian-plugin/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | 10 | /* Main Container */ 11 | .nordvpn-view { 12 | display: flex; 13 | flex-direction: column; 14 | height: 100%; 15 | padding: 0; 16 | margin: 0; 17 | } 18 | 19 | /* Top Bar */ 20 | .nordvpn-top-bar { 21 | display: flex; 22 | justify-content: space-between; 23 | align-items: center; 24 | padding: var(--size-4-2); 25 | border-bottom: var(--border-width) solid var(--background-modifier-border); 26 | background-color: var(--background-secondary); 27 | } 28 | 29 | .nordvpn-selectors { 30 | display: flex; 31 | gap: var(--size-4-2); 32 | } 33 | 34 | .nordvpn-selectors .dropdown { 35 | min-width: 180px; 36 | max-height: 300px; 37 | } 38 | 39 | /* Hide city dropdown when All Countries is selected */ 40 | .nordvpn-selectors[data-all-countries="true"] .city-select { 41 | display: none; 42 | } 43 | 44 | /* Dropdown scrollbar styling */ 45 | body:not(.native-scrollbars) ::-webkit-scrollbar { 46 | width: 0px; 47 | height: 0px; 48 | } 49 | 50 | .nordvpn-controls { 51 | display: flex; 52 | align-items: center; 53 | gap: var(--size-2-3); 54 | } 55 | 56 | .server-count { 57 | color: var(--text-muted); 58 | font-size: var(--font-ui-small); 59 | } 60 | 61 | /* Server Grid */ 62 | .nordvpn-server-grid { 63 | display: flex; 64 | flex-wrap: wrap; 65 | align-content: flex-start; 66 | padding: var(--size-2-3); 67 | overflow-y: auto; 68 | gap: var(--size-2-3); 69 | } 70 | 71 | /* Server Card */ 72 | .server-card { 73 | background-color: var(--background-secondary); 74 | border-radius: var(--radius-s); 75 | padding: var(--size-4-2); 76 | height: 100%; 77 | display: flex; 78 | flex-direction: row; 79 | justify-content: space-between; 80 | align-items: flex-start; 81 | transition: background-color 100ms ease-in-out; 82 | min-width: 0; 83 | } 84 | 85 | .server-card:hover { 86 | background-color: var(--background-secondary-alt); 87 | } 88 | 89 | .server-card-info { 90 | flex: 1; 91 | margin-right: var(--size-4-2); 92 | min-width: 0; 93 | overflow: hidden; 94 | } 95 | 96 | .server-card-name { 97 | font-size: var(--font-ui-medium); 98 | font-weight: var(--font-medium); 99 | color: var(--text-normal); 100 | margin-bottom: var(--size-2-2); 101 | white-space: nowrap; 102 | overflow: hidden; 103 | text-overflow: ellipsis; 104 | } 105 | 106 | .server-card-description { 107 | color: var(--text-muted); 108 | font-size: var(--font-ui-smaller); 109 | display: flex; 110 | align-items: center; 111 | min-width: 0; 112 | } 113 | 114 | .server-card-location { 115 | white-space: nowrap; 116 | overflow: hidden; 117 | text-overflow: ellipsis; 118 | flex: 1; 119 | min-width: 0; 120 | } 121 | 122 | .server-card-load { 123 | flex-shrink: 0; 124 | margin-left: var(--size-2-2); 125 | white-space: nowrap; 126 | } 127 | 128 | .server-card-load.mod-error { 129 | color: var(--text-error); 130 | } 131 | 132 | .server-card-load.mod-warning { 133 | color: var(--text-warning); 134 | } 135 | 136 | .server-card-load.mod-success { 137 | color: var(--text-success); 138 | } 139 | 140 | .server-card-icon-button { 141 | padding: var(--size-2-1) !important; 142 | background: none !important; 143 | box-shadow: none !important; 144 | opacity: 0.75; 145 | transition: opacity 100ms ease-in-out; 146 | height: 24px !important; 147 | width: 24px !important; 148 | display: flex !important; 149 | align-items: center; 150 | justify-content: center; 151 | } 152 | 153 | .server-card-icon-button svg { 154 | width: 16px; 155 | height: 16px; 156 | } 157 | 158 | .server-card-icon-button:hover { 159 | opacity: 1; 160 | color: var(--text-accent); 161 | } 162 | 163 | .server-card:hover .server-card-icon-button { 164 | opacity: 0.85; 165 | } 166 | 167 | .server-card:hover .server-card-icon-button:hover { 168 | opacity: 1; 169 | } 170 | 171 | /* QR Code Modal */ 172 | .nordvpn-qr-modal { 173 | text-align: center; 174 | } 175 | 176 | .nordvpn-qr-modal img { 177 | max-width: 300px; 178 | background-color: white; 179 | padding: var(--size-2-3); 180 | border-radius: var(--radius-s); 181 | margin: var(--size-4-2) auto; 182 | } 183 | 184 | /* Status Message */ 185 | .nordvpn-status { 186 | padding: var(--size-4-2); 187 | margin: var(--size-4-2); 188 | border-radius: var(--radius-s); 189 | } 190 | 191 | .nordvpn-status.error { 192 | background-color: var(--background-modifier-error); 193 | color: var(--text-error); 194 | } 195 | 196 | .nordvpn-status.success { 197 | background-color: var(--background-modifier-success); 198 | color: var(--text-success); 199 | } 200 | 201 | /* Accessibility */ 202 | @media (prefers-reduced-motion: reduce) { 203 | .server-card { 204 | transition: none; 205 | } 206 | } 207 | 208 | /* High Contrast Mode */ 209 | @media (prefers-contrast: more) { 210 | .server-card { 211 | border: 2px solid var(--background-modifier-border); 212 | } 213 | 214 | .nordvpn-qr-modal img { 215 | border: 2px solid var(--background-modifier-border); 216 | } 217 | } 218 | 219 | .server-card-actions { 220 | display: flex; 221 | gap: var(--size-2-2); 222 | align-items: flex-start; 223 | height: 24px; 224 | flex-shrink: 0; 225 | } 226 | 227 | /* Password Input Styling */ 228 | .password-input-wrapper { 229 | position: relative; 230 | display: flex; 231 | align-items: center; 232 | width: 100%; 233 | } 234 | 235 | .password-input { 236 | width: 100%; 237 | padding-right: 26px !important; 238 | font-family: var(--font-monospace); 239 | background-color: var(--background-primary); 240 | border: var(--input-border-width) solid var(--background-modifier-border); 241 | color: var(--text-normal); 242 | padding: var(--size-4-1) var(--size-4-2); 243 | border-radius: var(--input-radius); 244 | outline: none; 245 | transition: border-color 0.15s ease; 246 | } 247 | 248 | .password-input:focus { 249 | border-color: var(--interactive-accent); 250 | } 251 | 252 | .password-toggle-button { 253 | position: absolute; 254 | right: 6px; 255 | background: transparent !important; 256 | border: none; 257 | padding: 0; 258 | width: 16px; 259 | height: 16px; 260 | cursor: pointer; 261 | display: flex; 262 | align-items: center; 263 | justify-content: center; 264 | pointer-events: auto; 265 | z-index: 1; 266 | } 267 | 268 | .password-toggle-button:focus { 269 | outline: none; 270 | } 271 | 272 | .password-toggle-button:focus-visible { 273 | outline: 2px solid var(--interactive-accent); 274 | outline-offset: 2px; 275 | border-radius: 2px; 276 | } 277 | 278 | .password-toggle-icon { 279 | color: var(--text-muted); 280 | width: 16px; 281 | height: 16px; 282 | transition: color 0.15s ease; 283 | display: flex; 284 | align-items: center; 285 | justify-content: center; 286 | } 287 | 288 | .password-toggle-icon svg { 289 | width: 14px; 290 | height: 14px; 291 | } 292 | 293 | .password-toggle-button:hover .password-toggle-icon { 294 | color: var(--text-accent); 295 | } 296 | 297 | /* High Contrast Mode */ 298 | @media (prefers-contrast: more) { 299 | .password-input { 300 | border-width: 2px; 301 | } 302 | } 303 | 304 | /* Modal Buttons */ 305 | .nordvpn-modal-buttons { 306 | display: flex; 307 | justify-content: flex-end; 308 | gap: var(--size-4-2); 309 | margin-top: var(--size-4-2); 310 | } 311 | 312 | .nordvpn-modal-buttons button { 313 | padding: var(--size-4-1) var(--size-4-3); 314 | border-radius: var(--radius-s); 315 | font-size: var(--font-ui-small); 316 | cursor: pointer; 317 | background-color: var(--interactive-normal); 318 | border: 1px solid var(--background-modifier-border); 319 | color: var(--text-normal); 320 | } 321 | 322 | .nordvpn-modal-buttons button:hover { 323 | background-color: var(--interactive-hover); 324 | } 325 | 326 | .nordvpn-modal-buttons button:first-child { 327 | background-color: var(--interactive-accent); 328 | color: var(--text-on-accent); 329 | } 330 | 331 | .nordvpn-modal-buttons button:first-child:hover { 332 | background-color: var(--interactive-accent-hover); 333 | } 334 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/internal/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "mime" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "nordgen/internal/types" 16 | 17 | "github.com/andybalholm/brotli" 18 | ) 19 | 20 | const ( 21 | API_URL = "https://api.nordvpn.com/v1/servers?limit=16384&filters[servers_technologies][identifier]=wireguard_udp" 22 | PUBLIC_DIR = "./public" 23 | REFRESH = 5 * time.Minute 24 | ) 25 | 26 | type Store struct { 27 | sync.RWMutex 28 | assets map[string]*types.Asset 29 | servers map[string]types.ProcessedServer 30 | keys map[int]string 31 | regionIndex map[string]map[string][]string 32 | serverJson []byte 33 | serverEtag string 34 | indexRaw []byte 35 | indexAsset *types.Asset 36 | } 37 | 38 | var Core = &Store{ 39 | assets: make(map[string]*types.Asset), 40 | servers: make(map[string]types.ProcessedServer), 41 | keys: make(map[int]string), 42 | regionIndex: make(map[string]map[string][]string), 43 | } 44 | 45 | func (s *Store) Init() { 46 | fmt.Println("[INFO ] [Store] Initializing...") 47 | if err := s.loadAssets(PUBLIC_DIR); err != nil { 48 | fmt.Printf("[ERROR] [Store] Asset load failed: %v\n", err) 49 | } 50 | s.updateServers() 51 | go func() { 52 | ticker := time.NewTicker(REFRESH) 53 | for range ticker.C { 54 | s.updateServers() 55 | } 56 | }() 57 | fmt.Println("[INFO ] [Store] Ready.") 58 | } 59 | 60 | func (s *Store) loadAssets(dir string) error { 61 | entries, err := os.ReadDir(dir) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | for _, entry := range entries { 67 | path := filepath.Join(dir, entry.Name()) 68 | if entry.IsDir() { 69 | s.loadAssets(path) 70 | continue 71 | } 72 | if strings.HasSuffix(entry.Name(), ".br") { 73 | continue 74 | } 75 | 76 | content, err := os.ReadFile(path) 77 | if err != nil { 78 | continue 79 | } 80 | 81 | relPath, _ := filepath.Rel(PUBLIC_DIR, path) 82 | webPath := "/" + filepath.ToSlash(relPath) 83 | 84 | if webPath == "/index.html" { 85 | s.indexRaw = content 86 | continue 87 | } 88 | 89 | var brContent []byte 90 | if _, err := os.Stat(path + ".br"); err == nil { 91 | brContent, _ = os.ReadFile(path + ".br") 92 | } else { 93 | var buf bytes.Buffer 94 | w := brotli.NewWriterLevel(&buf, brotli.BestCompression) 95 | w.Write(content) 96 | w.Close() 97 | brContent = buf.Bytes() 98 | } 99 | 100 | mimeType := mime.TypeByExtension(filepath.Ext(path)) 101 | if mimeType == "" { 102 | mimeType = "application/octet-stream" 103 | } 104 | 105 | s.Lock() 106 | s.assets[webPath] = &types.Asset{ 107 | Content: content, 108 | Brotli: brContent, 109 | Mime: mimeType, 110 | Etag: fmt.Sprintf(`W/"%x-%x"`, len(content), time.Now().UnixMilli()), 111 | } 112 | s.Unlock() 113 | } 114 | return nil 115 | } 116 | 117 | func normalize(s string) string { 118 | var b strings.Builder 119 | b.Grow(len(s)) 120 | lastUnderscore := false 121 | for _, c := range strings.ToLower(s) { 122 | if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { 123 | b.WriteRune(c) 124 | lastUnderscore = false 125 | } else { 126 | if !lastUnderscore { 127 | b.WriteByte('_') 128 | lastUnderscore = true 129 | } 130 | } 131 | } 132 | return b.String() 133 | } 134 | 135 | func (s *Store) updateServers() { 136 | fmt.Println("[INFO ] [Store] Updating server list...") 137 | resp, err := http.Get(API_URL) 138 | if err != nil { 139 | fmt.Printf("[ERROR] [Store] Update failed: %v\n", err) 140 | return 141 | } 142 | defer resp.Body.Close() 143 | 144 | if resp.StatusCode != 200 { 145 | fmt.Printf("[ERROR] [Store] API Status: %s\n", resp.Status) 146 | return 147 | } 148 | 149 | var raw []types.RawServer 150 | if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { 151 | fmt.Printf("[ERROR] [Store] JSON Decode: %v\n", err) 152 | return 153 | } 154 | 155 | newServers := make(map[string]types.ProcessedServer, len(raw)) 156 | newKeys := make(map[string]int, len(raw)) 157 | keyMap := make(map[int]string, len(raw)) 158 | newRegionIndex := make(map[string]map[string][]string) 159 | payload := types.ServerPayload{ 160 | Headers: []string{"name", "load", "station"}, 161 | List: make(map[string]map[string][][]interface{}), 162 | } 163 | 164 | kID := 1 165 | 166 | for _, srv := range raw { 167 | if len(srv.Locations) == 0 { 168 | continue 169 | } 170 | 171 | name := normalize(srv.Name) 172 | if _, seen := newServers[name]; seen { 173 | continue 174 | } 175 | 176 | loc := srv.Locations[0] 177 | var pk string 178 | for _, tech := range srv.Technologies { 179 | for _, meta := range tech.Metadata { 180 | if meta.Name == "public_key" { 181 | pk = meta.Value 182 | break 183 | } 184 | } 185 | } 186 | 187 | if loc.Country.Code == "" || pk == "" { 188 | continue 189 | } 190 | 191 | id, exists := newKeys[pk] 192 | if !exists { 193 | id = kID 194 | kID++ 195 | newKeys[pk] = id 196 | keyMap[id] = pk 197 | } 198 | 199 | country := normalize(loc.Country.Name) 200 | city := normalize(loc.Country.City.Name) 201 | 202 | newServers[name] = types.ProcessedServer{ 203 | Name: name, 204 | Station: srv.Station, 205 | Hostname: srv.Hostname, 206 | Country: country, 207 | City: city, 208 | Code: loc.Country.Code, 209 | KeyID: id, 210 | } 211 | 212 | if payload.List[country] == nil { 213 | payload.List[country] = make(map[string][][]interface{}) 214 | newRegionIndex[country] = make(map[string][]string) 215 | } 216 | if newRegionIndex[country][city] == nil { 217 | newRegionIndex[country][city] = []string{} 218 | } 219 | 220 | payload.List[country][city] = append(payload.List[country][city], []interface{}{name, srv.Load, srv.Station}) 221 | newRegionIndex[country][city] = append(newRegionIndex[country][city], name) 222 | } 223 | 224 | jsonData, _ := json.Marshal(payload) 225 | etag := fmt.Sprintf(`W/"%s"`, strings.Trim(fmt.Sprintf("%x", time.Now().UnixNano()), "-")) 226 | 227 | s.Lock() 228 | s.servers = newServers 229 | s.keys = keyMap 230 | s.regionIndex = newRegionIndex 231 | s.serverJson = jsonData 232 | s.serverEtag = etag 233 | s.rebuildIndex() 234 | s.Unlock() 235 | 236 | fmt.Printf("[INFO ] [Store] Cached %d servers.\n", len(newServers)) 237 | } 238 | 239 | func (s *Store) rebuildIndex() { 240 | if s.indexRaw == nil { 241 | return 242 | } 243 | script := fmt.Sprintf(``, s.serverJson) 244 | htmlStr := strings.Replace(string(s.indexRaw), "", script+"", 1) 245 | content := []byte(htmlStr) 246 | 247 | var buf bytes.Buffer 248 | w := brotli.NewWriterLevel(&buf, brotli.BestCompression) 249 | w.Write(content) 250 | w.Close() 251 | 252 | s.indexAsset = &types.Asset{ 253 | Content: content, 254 | Brotli: buf.Bytes(), 255 | Mime: "text/html; charset=utf-8", 256 | Etag: s.serverEtag, 257 | } 258 | } 259 | 260 | func (s *Store) GetAsset(path string) *types.Asset { 261 | s.RLock() 262 | defer s.RUnlock() 263 | if path == "/" || path == "/index.html" { 264 | return s.indexAsset 265 | } 266 | return s.assets[path] 267 | } 268 | 269 | func (s *Store) GetServerList() ([]byte, string) { 270 | s.RLock() 271 | defer s.RUnlock() 272 | return s.serverJson, s.serverEtag 273 | } 274 | 275 | func (s *Store) GetServer(name string) (types.ProcessedServer, bool) { 276 | s.RLock() 277 | defer s.RUnlock() 278 | v, ok := s.servers[name] 279 | return v, ok 280 | } 281 | 282 | func (s *Store) GetKey(id int) (string, bool) { 283 | s.RLock() 284 | defer s.RUnlock() 285 | v, ok := s.keys[id] 286 | return v, ok 287 | } 288 | 289 | func (s *Store) GetBatch(country, city string) []types.ProcessedServer { 290 | s.RLock() 291 | defer s.RUnlock() 292 | 293 | cKey := normalize(country) 294 | tKey := normalize(city) 295 | 296 | if cKey == "" { 297 | results := make([]types.ProcessedServer, 0, len(s.servers)) 298 | for _, srv := range s.servers { 299 | results = append(results, srv) 300 | } 301 | return results 302 | } 303 | 304 | cities, ok := s.regionIndex[cKey] 305 | if !ok { 306 | return nil 307 | } 308 | 309 | if tKey == "" { 310 | var count int 311 | for _, names := range cities { 312 | count += len(names) 313 | } 314 | results := make([]types.ProcessedServer, 0, count) 315 | for _, names := range cities { 316 | for _, name := range names { 317 | if srv, exists := s.servers[name]; exists { 318 | results = append(results, srv) 319 | } 320 | } 321 | } 322 | return results 323 | } 324 | 325 | if names, ok := cities[tKey]; ok { 326 | results := make([]types.ProcessedServer, 0, len(names)) 327 | for _, name := range names { 328 | if srv, exists := s.servers[name]; exists { 329 | results = append(results, srv) 330 | } 331 | } 332 | return results 333 | } 334 | 335 | return nil 336 | } 337 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/zip" 5 | "bufio" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "regexp" 10 | "strings" 11 | "time" 12 | 13 | "nordgen/internal/store" 14 | "nordgen/internal/types" 15 | "nordgen/internal/wg" 16 | 17 | "github.com/gofiber/fiber/v2" 18 | "github.com/gofiber/fiber/v2/middleware/compress" 19 | "github.com/gofiber/fiber/v2/middleware/cors" 20 | "github.com/gofiber/fiber/v2/middleware/limiter" 21 | "github.com/skip2/go-qrcode" 22 | ) 23 | 24 | var ( 25 | rxToken = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) 26 | rxKey = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`) 27 | rxIPv4 = regexp.MustCompile(`^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`) 28 | 29 | httpClient = &http.Client{ 30 | Timeout: 10 * time.Second, 31 | Transport: &http.Transport{ 32 | MaxIdleConns: 100, 33 | MaxIdleConnsPerHost: 100, 34 | IdleConnTimeout: 90 * time.Second, 35 | }, 36 | } 37 | ) 38 | 39 | func parseCommon(key, dns, endpoint string, keepAlive *int) (types.ValidatedConfig, []string) { 40 | var errs []string 41 | if key != "" && !rxKey.MatchString(key) { 42 | errs = append(errs, "Invalid Private Key") 43 | } 44 | 45 | cleanDns := "103.86.96.100" 46 | if dns != "" { 47 | parts := strings.Split(dns, ",") 48 | valid := true 49 | for _, p := range parts { 50 | if !rxIPv4.MatchString(strings.TrimSpace(p)) { 51 | valid = false 52 | break 53 | } 54 | } 55 | if !valid { 56 | errs = append(errs, "Invalid DNS IP") 57 | } else { 58 | cleanDns = dns 59 | } 60 | } 61 | 62 | if endpoint != "" && endpoint != "hostname" && endpoint != "station" { 63 | errs = append(errs, "Invalid endpoint type") 64 | } 65 | 66 | ka := 25 67 | if keepAlive != nil { 68 | if *keepAlive < 15 || *keepAlive > 120 { 69 | errs = append(errs, "Invalid keepalive") 70 | } else { 71 | ka = *keepAlive 72 | } 73 | } 74 | 75 | return types.ValidatedConfig{ 76 | PrivateKey: key, 77 | DNS: cleanDns, 78 | UseStation: endpoint == "station", 79 | KeepAlive: ka, 80 | }, errs 81 | } 82 | 83 | func validateConfig(b types.ConfigRequest) (types.ValidatedConfig, string) { 84 | cfg, errs := parseCommon(b.PrivateKey, b.DNS, b.Endpoint, b.KeepAlive) 85 | 86 | if b.Country == "" { 87 | errs = append(errs, "Missing country") 88 | } 89 | if b.City == "" { 90 | errs = append(errs, "Missing city") 91 | } 92 | if b.Name == "" { 93 | errs = append(errs, "Missing name") 94 | } 95 | 96 | cfg.Name = b.Name 97 | if len(errs) > 0 { 98 | return types.ValidatedConfig{}, strings.Join(errs, ", ") 99 | } 100 | return cfg, "" 101 | } 102 | 103 | func validateBatch(b types.BatchConfigReq) (types.ValidatedConfig, string) { 104 | cfg, errs := parseCommon(b.PrivateKey, b.DNS, b.Endpoint, b.KeepAlive) 105 | if len(errs) > 0 { 106 | return types.ValidatedConfig{}, strings.Join(errs, ", ") 107 | } 108 | return cfg, "" 109 | } 110 | 111 | func sanitizeFilename(s string) string { 112 | var b strings.Builder 113 | b.Grow(len(s)) 114 | for _, c := range s { 115 | if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { 116 | b.WriteRune(c) 117 | } else if c == ' ' { 118 | b.WriteByte('_') 119 | } 120 | } 121 | return b.String() 122 | } 123 | 124 | func extractFirstNumber(s string) string { 125 | start := -1 126 | for i := 0; i < len(s); i++ { 127 | if s[i] >= '0' && s[i] <= '9' { 128 | if start == -1 { 129 | start = i 130 | } 131 | } else { 132 | if start != -1 { 133 | return s[start:i] 134 | } 135 | } 136 | } 137 | if start != -1 { 138 | return s[start:] 139 | } 140 | return "" 141 | } 142 | 143 | func main() { 144 | store.Core.Init() 145 | 146 | app := fiber.New(fiber.Config{ 147 | DisableStartupMessage: false, 148 | BodyLimit: 4 * 1024 * 1024, 149 | }) 150 | 151 | app.Use(cors.New()) 152 | 153 | api := app.Group("/api") 154 | api.Use(limiter.New(limiter.Config{ 155 | Max: 100, 156 | Expiration: 1 * time.Minute, 157 | KeyGenerator: func(c *fiber.Ctx) string { 158 | if k := c.Get("x-test-key"); k != "" { 159 | return k 160 | } 161 | return c.IP() 162 | }, 163 | })) 164 | api.Use(compress.New()) 165 | 166 | api.Get("/servers", func(c *fiber.Ctx) error { 167 | data, etag := store.Core.GetServerList() 168 | if data == nil { 169 | return c.Status(503).JSON(fiber.Map{"error": "Initializing"}) 170 | } 171 | if c.Get("if-none-match") == etag { 172 | return c.SendStatus(304) 173 | } 174 | c.Set("ETag", etag) 175 | c.Set("Cache-Control", "public, max-age=300") 176 | c.Set("Content-Type", "application/json; charset=utf-8") 177 | return c.Send(data) 178 | }) 179 | 180 | api.Post("/key", func(c *fiber.Ctx) error { 181 | var body struct { 182 | Token string `json:"token"` 183 | } 184 | if err := c.BodyParser(&body); err != nil { 185 | return c.SendStatus(400) 186 | } 187 | 188 | if !rxToken.MatchString(body.Token) { 189 | return c.Status(400).JSON(fiber.Map{"error": "Invalid token"}) 190 | } 191 | 192 | req, _ := http.NewRequest("GET", "https://api.nordvpn.com/v1/users/services/credentials", nil) 193 | req.Header.Set("Authorization", "Bearer token:"+body.Token) 194 | 195 | resp, err := httpClient.Do(req) 196 | if err != nil { 197 | return c.Status(503).JSON(fiber.Map{"error": "Upstream error"}) 198 | } 199 | defer resp.Body.Close() 200 | 201 | if resp.StatusCode == 401 { 202 | return c.Status(401).JSON(fiber.Map{"error": "Expired token"}) 203 | } 204 | if resp.StatusCode != 200 { 205 | return c.Status(503).JSON(fiber.Map{"error": "Upstream error"}) 206 | } 207 | 208 | var data struct { 209 | Key string `json:"nordlynx_private_key"` 210 | } 211 | if json.NewDecoder(resp.Body).Decode(&data) != nil { 212 | return c.SendStatus(500) 213 | } 214 | 215 | return c.JSON(fiber.Map{"key": data.Key}) 216 | }) 217 | 218 | handleConfig := func(c *fiber.Ctx, outputType string) error { 219 | var body types.ConfigRequest 220 | if err := c.BodyParser(&body); err != nil { 221 | return c.SendStatus(400) 222 | } 223 | 224 | cfg, errMsg := validateConfig(body) 225 | if errMsg != "" { 226 | return c.Status(400).JSON(fiber.Map{"error": errMsg}) 227 | } 228 | 229 | srv, ok := store.Core.GetServer(cfg.Name) 230 | if !ok { 231 | return c.Status(404).JSON(fiber.Map{"error": "Server not found"}) 232 | } 233 | 234 | pk, ok := store.Core.GetKey(srv.KeyID) 235 | if !ok { 236 | return c.Status(500).JSON(fiber.Map{"error": "Key missing"}) 237 | } 238 | 239 | confContent := wg.Build(srv, pk, cfg) 240 | c.Set("Cache-Control", "no-store") 241 | 242 | if outputType == "text" { 243 | return c.SendString(confContent) 244 | } 245 | 246 | num := extractFirstNumber(srv.Name) 247 | if num == "" { 248 | num = "wg" 249 | } 250 | fname := fmt.Sprintf("%s%s.conf", strings.ToLower(srv.Code), num) 251 | 252 | if outputType == "file" { 253 | c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fname)) 254 | c.Set("Content-Type", "application/x-wireguard-config") 255 | return c.SendString(confContent) 256 | } 257 | 258 | png, err := qrcode.Encode(confContent, qrcode.Medium, 256) 259 | if err != nil { 260 | return c.SendStatus(500) 261 | } 262 | c.Set("Content-Type", "image/png") 263 | return c.Send(png) 264 | } 265 | 266 | api.Post("/config", func(c *fiber.Ctx) error { return handleConfig(c, "text") }) 267 | api.Post("/config/download", func(c *fiber.Ctx) error { return handleConfig(c, "file") }) 268 | api.Post("/config/qr", func(c *fiber.Ctx) error { return handleConfig(c, "qr") }) 269 | 270 | api.Post("/config/batch", func(c *fiber.Ctx) error { 271 | var body types.BatchConfigReq 272 | if err := c.BodyParser(&body); err != nil { 273 | return c.SendStatus(400) 274 | } 275 | 276 | cfg, errMsg := validateBatch(body) 277 | if errMsg != "" { 278 | return c.Status(400).JSON(fiber.Map{"error": errMsg}) 279 | } 280 | 281 | servers := store.Core.GetBatch(body.Country, body.City) 282 | if len(servers) == 0 { 283 | return c.Status(404).JSON(fiber.Map{"error": "No servers found"}) 284 | } 285 | 286 | baseName := "NordVPN_All" 287 | if body.Country != "" { 288 | baseName = "NordVPN_" + sanitizeFilename(body.Country) 289 | if body.City != "" { 290 | baseName += "_" + sanitizeFilename(body.City) 291 | } 292 | } 293 | 294 | c.Set("Content-Type", "application/octet-stream") 295 | c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.nord"`, baseName)) 296 | c.Set("Cache-Control", "no-store") 297 | 298 | c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { 299 | zw := zip.NewWriter(w) 300 | defer zw.Close() 301 | 302 | usedPaths := make(map[string]int) 303 | 304 | for _, srv := range servers { 305 | pk, ok := store.Core.GetKey(srv.KeyID) 306 | if !ok { 307 | continue 308 | } 309 | 310 | num := extractFirstNumber(srv.Name) 311 | if num == "" { 312 | num = "wg" 313 | } 314 | fName := fmt.Sprintf("%s%s.conf", strings.ToLower(srv.Code), num) 315 | 316 | var path string 317 | if body.Country == "" { 318 | path = fmt.Sprintf("%s/%s/%s", srv.Country, srv.City, fName) 319 | } else if body.City == "" { 320 | path = fmt.Sprintf("%s/%s", srv.City, fName) 321 | } else { 322 | path = fName 323 | } 324 | 325 | if val, exists := usedPaths[path]; exists { 326 | originalPath := path 327 | base := originalPath[:len(originalPath)-5] 328 | idx := val 329 | if idx == 0 { 330 | idx = 1 331 | } 332 | for { 333 | candidate := fmt.Sprintf("%s_%d.conf", base, idx) 334 | idx++ 335 | if _, occupied := usedPaths[candidate]; !occupied { 336 | path = candidate 337 | usedPaths[originalPath] = idx 338 | break 339 | } 340 | } 341 | } 342 | 343 | usedPaths[path] = 0 344 | 345 | f, err := zw.CreateHeader(&zip.FileHeader{ 346 | Name: path, 347 | Method: zip.Store, 348 | }) 349 | if err != nil { 350 | continue 351 | } 352 | 353 | conf := wg.Build(srv, pk, cfg) 354 | f.Write([]byte(conf)) 355 | } 356 | }) 357 | 358 | return nil 359 | }) 360 | 361 | app.Use(func(c *fiber.Ctx) error { 362 | path := c.Path() 363 | asset := store.Core.GetAsset(path) 364 | 365 | if asset == nil { 366 | if strings.HasPrefix(path, "/api") { 367 | return c.Status(404).JSON(fiber.Map{"message": "Endpoint not found"}) 368 | } 369 | asset = store.Core.GetAsset("/index.html") 370 | if asset != nil { 371 | c.Set("Content-Type", "text/html") 372 | return c.Send(asset.Content) 373 | } 374 | return c.SendStatus(404) 375 | } 376 | 377 | if c.Get("if-none-match") == asset.Etag { 378 | return c.SendStatus(304) 379 | } 380 | 381 | c.Set("ETag", asset.Etag) 382 | c.Set("Content-Type", asset.Mime) 383 | 384 | cc := "public, max-age=300" 385 | if strings.HasPrefix(path, "/assets") { 386 | cc = "public, max-age=31536000, immutable" 387 | } 388 | c.Set("Cache-Control", cc) 389 | 390 | if asset.Brotli != nil && strings.Contains(c.Get("accept-encoding"), "br") { 391 | c.Set("Content-Encoding", "br") 392 | return c.Send(asset.Brotli) 393 | } 394 | return c.Send(asset.Content) 395 | }) 396 | 397 | app.Listen(":3000") 398 | } 399 | -------------------------------------------------------------------------------- /web-version-V2/web-version-V2-Frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 125 | 126 | -------------------------------------------------------------------------------- /Obsidian-plugin/src/main.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, Plugin, TFolder, TFile, TextFileView, WorkspaceLeaf } from 'obsidian'; 2 | import axios from 'axios'; 3 | import { isValidPrivateKey, generateKey, normalizeServerName } from './utils'; 4 | import { ApiService, ConfigRequest } from './api.service'; 5 | import { VIEW_TYPE_NORDVPN, DEFAULT_SETTINGS, CACHE_EXPIRY_TIME, TOKEN_REGEX, NORDVPN_ICON } from './constants'; 6 | import { NordVPNView } from './view'; 7 | import { NordVPNSettingTab } from './settings'; 8 | import { NordVPNPluginSettings, ServerGroup, ServerInfo, ConfigServerInfo } from './types'; 9 | 10 | class ConfigFileView extends TextFileView { 11 | private editor: HTMLTextAreaElement; 12 | 13 | getViewType(): string { 14 | return "conf"; 15 | } 16 | 17 | getDisplayText(): string { 18 | return this.file?.basename || "Config File"; 19 | } 20 | 21 | getIcon(): string { 22 | return "document"; 23 | } 24 | 25 | async onOpen() { 26 | if (this.file?.extension === 'conf') { 27 | const content = await this.app.vault.read(this.file); 28 | this.setViewData(content); 29 | } 30 | } 31 | 32 | async setViewData(data: string, clear: boolean = false) { 33 | this.contentEl.empty(); 34 | 35 | // Create editor container with monospace font 36 | const container = this.contentEl.createDiv({ 37 | cls: 'conf-editor-container' 38 | }); 39 | 40 | // Create textarea for editing 41 | this.editor = container.createEl('textarea', { 42 | cls: 'conf-editor', 43 | attr: { 44 | spellcheck: 'false' 45 | } 46 | }); 47 | 48 | // Set initial content 49 | this.editor.value = data; 50 | 51 | // Handle changes 52 | this.editor.addEventListener('input', () => { 53 | this.requestSave(); 54 | }); 55 | 56 | // Add some basic styling 57 | container.style.cssText = 'height: 100%; padding: 10px;'; 58 | this.editor.style.cssText = ` 59 | width: 100%; 60 | height: 100%; 61 | resize: none; 62 | font-family: var(--font-monospace); 63 | background-color: var(--background-primary); 64 | color: var(--text-normal); 65 | border: 1px solid var(--background-modifier-border); 66 | border-radius: 4px; 67 | padding: 10px; 68 | line-height: 1.5; 69 | `; 70 | } 71 | 72 | clear() { 73 | this.contentEl.empty(); 74 | } 75 | 76 | getViewData(): string { 77 | return this.editor?.value || ''; 78 | } 79 | 80 | requestSave = async () => { 81 | if (this.file) { 82 | await this.app.vault.modify(this.file, this.getViewData()); 83 | } 84 | } 85 | } 86 | 87 | export default class NordVPNPlugin extends Plugin { 88 | settings: NordVPNPluginSettings; 89 | private serverCache: ServerGroup | null = null; 90 | private lastETag: string | null = null; 91 | private lastCacheTime: number | null = null; 92 | private apiService: ApiService; 93 | private privateKey: string | null = null; 94 | private _d: { iv: string, data: string } | null = null; 95 | 96 | async onload() { 97 | await this.loadSettings(); 98 | this.apiService = new ApiService(this.settings.apiUrl); 99 | await this.loadPrivateKey(); 100 | 101 | // Register .conf file extension and view handler 102 | this.registerExtensions(['conf'], 'conf'); 103 | this.registerView('conf', (leaf) => new ConfigFileView(leaf)); 104 | 105 | this.registerView( 106 | VIEW_TYPE_NORDVPN, 107 | (leaf) => new NordVPNView(leaf, this) 108 | ); 109 | 110 | // Add ribbon icon with custom SVG 111 | const ribbonIconEl = this.addRibbonIcon('', 'NordVPN Config Generator', () => { 112 | this.activateView(); 113 | }); 114 | ribbonIconEl.innerHTML = NORDVPN_ICON; 115 | 116 | this.addSettingTab(new NordVPNSettingTab(this.app, this)); 117 | } 118 | 119 | async onunload() { 120 | // Save private key before unloading 121 | if (this.privateKey) { 122 | try { 123 | await this.savePrivateKey(); 124 | } catch (error) { 125 | console.error('Failed to save private key during unload:', error); 126 | } 127 | } 128 | this.clearCache(); 129 | } 130 | 131 | private clearCache() { 132 | this.serverCache = null; 133 | this.lastETag = null; 134 | this.lastCacheTime = null; 135 | } 136 | 137 | private async loadPrivateKey() { 138 | try { 139 | const data = await this.loadData(); 140 | if (data?._d?.iv && data?._d?.data && this.apiService) { 141 | const key = generateKey(CACHE_EXPIRY_TIME); 142 | try { 143 | const decrypted = this.apiService.decryptData(data._d.iv, data._d.data, key); 144 | if (isValidPrivateKey(decrypted)) { 145 | this.privateKey = decrypted; 146 | this._d = data._d; 147 | } 148 | } catch (error) { 149 | console.error('Failed to decrypt private key:', error); 150 | this.privateKey = null; 151 | this._d = null; 152 | } 153 | } 154 | } catch (error) { 155 | console.error('Error loading private key:', error); 156 | this.privateKey = null; 157 | this._d = null; 158 | } 159 | } 160 | 161 | private async savePrivateKey() { 162 | try { 163 | if (this.privateKey) { 164 | if (!isValidPrivateKey(this.privateKey)) { 165 | throw new Error('Invalid private key format'); 166 | } 167 | const key = generateKey(CACHE_EXPIRY_TIME); 168 | this._d = this.apiService.encryptData(this.privateKey, key); 169 | 170 | const data = await this.loadData() || {}; 171 | data._d = this._d; 172 | await this.saveData(data); 173 | } else { 174 | const data = await this.loadData() || {}; 175 | delete data._d; 176 | this._d = null; 177 | await this.saveData(data); 178 | } 179 | } catch (error) { 180 | console.error('Failed to save private key:', error); 181 | throw error; 182 | } 183 | } 184 | 185 | async loadSettings() { 186 | const loadedData = await this.loadData() || {}; 187 | 188 | // Clean up any sensitive data that might have been saved 189 | const sensitiveKeys = ['token']; 190 | let needsSave = false; 191 | sensitiveKeys.forEach(key => { 192 | if (key in loadedData) { 193 | delete loadedData[key]; 194 | needsSave = true; 195 | } 196 | }); 197 | 198 | // Merge settings, prioritizing saved values over defaults 199 | this.settings = { 200 | ...DEFAULT_SETTINGS, // Start with defaults 201 | ...loadedData // Override with saved values 202 | }; 203 | 204 | // If we found and removed sensitive data, save the cleaned settings 205 | if (needsSave) { 206 | await this.saveData(loadedData); 207 | } 208 | } 209 | 210 | async saveSettings() { 211 | try { 212 | // First save private key to ensure it is persisted 213 | await this.savePrivateKey(); 214 | 215 | // Load existing data to preserve encryptedPrivateKey 216 | const data = await this.loadData() || {}; 217 | 218 | // Update with new settings while preserving _d 219 | const updatedData = { 220 | ...data, 221 | dns: this.settings.dns, 222 | endpoint_type: this.settings.endpoint_type, 223 | keepalive: this.settings.keepalive, 224 | outputFolder: this.settings.outputFolder, 225 | apiUrl: this.settings.apiUrl, 226 | _d: this._d // Ensure _d is included in the save 227 | }; 228 | 229 | // Save all data 230 | await this.saveData(updatedData); 231 | 232 | // Update API service after settings are saved 233 | this.apiService = new ApiService(this.settings.apiUrl); 234 | 235 | // Update views after all saves are complete 236 | this.app.workspace.getLeavesOfType(VIEW_TYPE_NORDVPN).forEach(leaf => { 237 | const view = leaf.view as NordVPNView; 238 | view.updatePrivateKey(); 239 | }); 240 | } catch (error) { 241 | console.error('Failed to save settings:', error); 242 | throw error; // Re-throw to notify callers 243 | } 244 | } 245 | 246 | async ensureOutputFolder(): Promise { 247 | const folderPath = this.settings.outputFolder; 248 | const configFolder = this.app.vault.getAbstractFileByPath(folderPath) as TFolder; 249 | 250 | if (!configFolder) { 251 | await this.app.vault.createFolder(folderPath); 252 | return this.app.vault.getAbstractFileByPath(folderPath) as TFolder; 253 | } 254 | 255 | return configFolder; 256 | } 257 | 258 | async validateToken(token: string): Promise { 259 | if (!token || !TOKEN_REGEX.test(token)) { 260 | throw new Error('Invalid token format. Token must be a 64-character hexadecimal string.'); 261 | } 262 | return this.apiService.validateToken(token); 263 | } 264 | 265 | getPrivateKey(): string { 266 | if (!this.privateKey) { 267 | throw new Error('No private key available. Please generate one using your NordVPN token.'); 268 | } 269 | return this.privateKey; 270 | } 271 | 272 | async getServers(): Promise { 273 | const now = Date.now(); 274 | 275 | if (this.serverCache && this.lastCacheTime && (now - this.lastCacheTime) < CACHE_EXPIRY_TIME) { 276 | return this.serverCache; 277 | } 278 | 279 | const { data, etag } = await this.apiService.getServers(this.lastETag || undefined); 280 | 281 | if (data === null && this.serverCache) { 282 | this.lastCacheTime = now; 283 | return this.serverCache; 284 | } 285 | 286 | if (etag) { 287 | this.lastETag = etag; 288 | } 289 | 290 | this.serverCache = data; 291 | this.lastCacheTime = now; 292 | return data; 293 | } 294 | 295 | private sanitizeName(name: string): string { 296 | return name.toLowerCase() 297 | .replace(/\s+/g, '_') 298 | .replace(/(\d+)/g, '_$1') 299 | .replace(/and/g, '_and_') 300 | .replace(/_{2,}/g, '_') 301 | .replace(/^_+|_+$/g, '') 302 | .replace(/[^a-z0-9_]/g, '_') 303 | .replace(/_{2,}/g, '_'); 304 | } 305 | 306 | private sanitizeApiName(name: string): string { 307 | // For API requests, we only lowercase and remove spaces 308 | return name.toLowerCase().replace(/\s+/g, ''); 309 | } 310 | 311 | private createConfigRequest(privateKey: string | null, server: { country: string; city: string; name: string }): ConfigRequest { 312 | return { 313 | country: this.sanitizeApiName(server.country), 314 | city: this.sanitizeApiName(server.city), 315 | name: this.sanitizeApiName(server.name), 316 | ...(privateKey && { privateKey }), 317 | dns: this.settings.dns, 318 | endpoint: this.settings.endpoint_type, 319 | keepalive: this.settings.keepalive 320 | }; 321 | } 322 | 323 | async generateConfig(privateKey: string | null, server: { country: string; city: string; name: string }): Promise { 324 | return this.apiService.generateConfig(this.createConfigRequest(privateKey, server)); 325 | } 326 | 327 | async generateQRCode(privateKey: string | null, server: { country: string; city: string; name: string }): Promise { 328 | const blob = await this.apiService.generateQRCode(this.createConfigRequest(privateKey, server)); 329 | return URL.createObjectURL(blob); 330 | } 331 | 332 | async saveConfig(privateKey: string | null, server: ConfigServerInfo, basePath: string) { 333 | const config = await this.generateConfig(privateKey, { 334 | country: this.sanitizeApiName(server.country), 335 | city: this.sanitizeApiName(server.city), 336 | name: this.sanitizeApiName(server.name) 337 | }); 338 | 339 | try { 340 | // Create the full path for the file 341 | const filePath = `${this.settings.outputFolder}/${basePath}/${this.sanitizeName(server.country)}/${this.sanitizeName(server.city)}/${this.sanitizeName(server.name)}.conf`.replace(/\/+/g, '/'); 342 | 343 | // Ensure the parent folders exist 344 | const folders = filePath.split('/').slice(0, -1); 345 | let currentPath = ''; 346 | for (const folder of folders) { 347 | currentPath += folder + '/'; 348 | try { 349 | await this.app.vault.createFolder(currentPath.slice(0, -1)); 350 | } catch (error) { 351 | // Ignore if folder exists 352 | } 353 | } 354 | 355 | // Get or create the file 356 | const existingFile = this.app.vault.getAbstractFileByPath(filePath); 357 | if (existingFile instanceof TFile) { 358 | await this.app.vault.modify(existingFile, config); 359 | } else { 360 | await this.app.vault.create(filePath, config); 361 | } 362 | 363 | new Notice(`Configuration saved to ${filePath}`); 364 | } catch (error) { 365 | new Notice(`Failed to save config for ${server.name}: ${error.message}`); 366 | throw error; 367 | } 368 | } 369 | 370 | async activateView() { 371 | const { workspace } = this.app; 372 | let leaf = workspace.getLeavesOfType(VIEW_TYPE_NORDVPN)[0] || workspace.getLeaf(false); 373 | await leaf.setViewState({ type: VIEW_TYPE_NORDVPN, active: true }); 374 | workspace.revealLeaf(leaf); 375 | } 376 | 377 | async setPrivateKey(privateKey: string) { 378 | if (!isValidPrivateKey(privateKey)) { 379 | throw new Error('Invalid WireGuard private key format'); 380 | } 381 | this.privateKey = privateKey; 382 | await this.saveSettings(); 383 | } 384 | 385 | async clearPrivateKey() { 386 | this.privateKey = null; 387 | await this.saveSettings(); 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /Obsidian-plugin/src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting, setIcon, Notice } from 'obsidian'; 2 | import { isValidPrivateKey } from './utils'; 3 | import NordVPNPlugin from './main'; 4 | import { DEFAULT_SETTINGS, TOKEN_REGEX } from './constants'; 5 | 6 | export class NordVPNSettingTab extends PluginSettingTab { 7 | plugin: NordVPNPlugin; 8 | private tokenInput: HTMLInputElement | null = null; 9 | private privateKeyInput: HTMLInputElement | null = null; 10 | 11 | constructor(app: App, plugin: NordVPNPlugin) { 12 | super(app, plugin); 13 | this.plugin = plugin; 14 | } 15 | 16 | private createPasswordInput(containerEl: HTMLElement, placeholder: string): HTMLInputElement { 17 | const wrapper = containerEl.createDiv({ cls: 'password-input-wrapper' }); 18 | 19 | const input = wrapper.createEl('input', { 20 | type: 'password', 21 | placeholder: placeholder, 22 | cls: 'password-input' 23 | }); 24 | 25 | const toggleButton = wrapper.createEl('button', { 26 | cls: 'password-toggle-button', 27 | attr: { 'aria-label': 'Toggle visibility' } 28 | }); 29 | 30 | const icon = toggleButton.createSpan({ cls: 'password-toggle-icon' }); 31 | setIcon(icon, 'eye-off'); 32 | 33 | toggleButton.addEventListener('click', (e) => { 34 | e.preventDefault(); 35 | const isPassword = input.type === 'password'; 36 | input.type = isPassword ? 'text' : 'password'; 37 | setIcon(icon, isPassword ? 'eye' : 'eye-off'); 38 | }); 39 | 40 | return input; 41 | } 42 | 43 | display(): void { 44 | const { containerEl } = this; 45 | containerEl.empty(); 46 | 47 | containerEl.createEl('h2', { text: 'NordVPN Config Generator Settings' }); 48 | 49 | // Token input with generate key button 50 | const tokenSetting = new Setting(containerEl) 51 | .setName('NordVPN Token') 52 | .setDesc('Your NordVPN API token (64-character hexadecimal string)') 53 | .addButton(button => button 54 | .setButtonText('Generate Key') 55 | .onClick(async () => { 56 | const token = this.tokenInput?.value?.trim(); 57 | if (!token || !TOKEN_REGEX.test(token)) { 58 | const message = !token 59 | ? 'Please enter a token first' 60 | : 'Invalid token format. Token must be a 64-character hexadecimal string.'; 61 | new Notice(message); 62 | if (token && this.tokenInput) { 63 | this.tokenInput.value = ''; 64 | } 65 | return; 66 | } 67 | try { 68 | const privateKey = await this.plugin.validateToken(token); 69 | if (privateKey) { 70 | if (!isValidPrivateKey(privateKey)) { 71 | new Notice('API returned an invalid private key format'); 72 | return; 73 | } 74 | // Use the new setPrivateKey method 75 | await this.plugin.setPrivateKey(privateKey); 76 | if (this.privateKeyInput) { 77 | this.privateKeyInput.value = privateKey; 78 | } 79 | new Notice('Private key generated and saved successfully'); 80 | if (this.tokenInput) { 81 | this.tokenInput.value = ''; 82 | } 83 | } 84 | } catch (error) { 85 | new Notice(`Failed to generate key: ${error.message}`); 86 | } 87 | })); 88 | 89 | const tokenInputContainer = tokenSetting.controlEl.createDiv(); 90 | this.tokenInput = this.createPasswordInput(tokenInputContainer, 'Enter your token'); 91 | 92 | // Private key input with custom password field 93 | const privateKeySetting = new Setting(containerEl) 94 | .setName('WireGuard Private Key') 95 | .setDesc('Your WireGuard private key (44-character Base64 string ending with "=")'); 96 | 97 | const privateKeyInputContainer = privateKeySetting.controlEl.createDiv(); 98 | this.privateKeyInput = this.createPasswordInput(privateKeyInputContainer, 'Enter your private key'); 99 | 100 | try { 101 | const privateKey = this.plugin.getPrivateKey(); 102 | if (privateKey) { 103 | this.privateKeyInput.value = privateKey; 104 | } 105 | } catch (error) { 106 | // No private key available, leave input empty 107 | } 108 | 109 | this.privateKeyInput.addEventListener('change', async () => { 110 | const input = this.privateKeyInput; 111 | if (!input) return; 112 | 113 | const value = input.value; 114 | if (value) { 115 | if (!isValidPrivateKey(value)) { 116 | new Notice('Invalid WireGuard private key format. Must be a 44-character Base64 string ending with "="'); 117 | input.value = ''; 118 | await this.plugin.clearPrivateKey(); 119 | return; 120 | } 121 | await this.plugin.setPrivateKey(value); 122 | } else { 123 | await this.plugin.clearPrivateKey(); 124 | } 125 | }); 126 | 127 | new Setting(containerEl) 128 | .setName('DNS Servers') 129 | .setDesc('Comma-separated list of DNS servers (e.g., "103.86.96.100, 8.8.8.8")') 130 | .addText(text => { 131 | text.setPlaceholder('103.86.96.100, 8.8.8.8') 132 | .setValue(this.plugin.settings.dns); 133 | 134 | const inputEl = text.inputEl; 135 | inputEl.addEventListener('blur', async () => { 136 | const value = inputEl.value.trim(); 137 | 138 | // Allow empty value to reset to default 139 | if (!value) { 140 | this.plugin.settings.dns = DEFAULT_SETTINGS.dns; 141 | inputEl.value = this.plugin.settings.dns; 142 | await this.plugin.saveSettings(); 143 | return; 144 | } 145 | 146 | // Split by comma and clean up whitespace 147 | const servers = value.split(',').map(s => s.trim()).filter(s => s); 148 | 149 | // IPv4 validation regex 150 | const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; 151 | 152 | // Check each server 153 | const invalidServers = servers.filter(server => !ipv4Regex.test(server)); 154 | 155 | if (invalidServers.length > 0) { 156 | new Notice(`Invalid DNS server format: ${invalidServers.join(', ')}\nMust be valid IPv4 addresses`); 157 | this.plugin.settings.dns = DEFAULT_SETTINGS.dns; 158 | inputEl.value = this.plugin.settings.dns; 159 | await this.plugin.saveSettings(); 160 | return; 161 | } 162 | 163 | // Save the properly formatted DNS string 164 | this.plugin.settings.dns = servers.join(', '); 165 | inputEl.value = this.plugin.settings.dns; // Normalize the format 166 | await this.plugin.saveSettings(); 167 | }); 168 | 169 | return text; 170 | }); 171 | 172 | new Setting(containerEl) 173 | .setName('Endpoint Type') 174 | .setDesc('Use hostname or station (IP) for server endpoint') 175 | .addDropdown(dropdown => dropdown 176 | .addOption('hostname', 'Hostname') 177 | .addOption('station', 'Station (IP)') 178 | .setValue(this.plugin.settings.endpoint_type) 179 | .onChange(async (value: 'hostname' | 'station') => { 180 | this.plugin.settings.endpoint_type = value; 181 | await this.plugin.saveSettings(); 182 | })); 183 | 184 | new Setting(containerEl) 185 | .setName('Keepalive Interval') 186 | .setDesc('Keepalive interval in seconds (15-120)') 187 | .addText(text => { 188 | text.setPlaceholder('25') 189 | .setValue(String(this.plugin.settings.keepalive)); 190 | 191 | const inputEl = text.inputEl; 192 | inputEl.addEventListener('blur', async () => { 193 | const value = inputEl.value; 194 | const numValue = parseInt(value); 195 | 196 | if (isNaN(numValue) || numValue < 15 || numValue > 120) { 197 | new Notice('Keepalive interval must be between 15 and 120 seconds'); 198 | this.plugin.settings.keepalive = DEFAULT_SETTINGS.keepalive; 199 | inputEl.value = String(this.plugin.settings.keepalive); 200 | await this.plugin.saveSettings(); 201 | return; 202 | } 203 | 204 | this.plugin.settings.keepalive = numValue; 205 | await this.plugin.saveSettings(); 206 | }); 207 | 208 | return text; 209 | }); 210 | 211 | new Setting(containerEl) 212 | .setName('Output Folder') 213 | .setDesc('Folder where configuration files will be saved') 214 | .addText(text => { 215 | text.setPlaceholder('nordvpn-configs') 216 | .setValue(this.plugin.settings.outputFolder); 217 | 218 | const inputEl = text.inputEl; 219 | inputEl.addEventListener('blur', async () => { 220 | const value = inputEl.value.trim(); 221 | 222 | if (!value) { 223 | this.plugin.settings.outputFolder = DEFAULT_SETTINGS.outputFolder; 224 | inputEl.value = this.plugin.settings.outputFolder; 225 | } else { 226 | this.plugin.settings.outputFolder = value; 227 | } 228 | await this.plugin.saveSettings(); 229 | }); 230 | 231 | return text; 232 | }); 233 | 234 | new Setting(containerEl) 235 | .setName('API URL') 236 | .setDesc('URL of the NordVPN Config Generator API') 237 | .addText(text => { 238 | text.setPlaceholder('http://localhost:3000') 239 | .setValue(this.plugin.settings.apiUrl); 240 | 241 | const inputEl = text.inputEl; 242 | inputEl.addEventListener('blur', async () => { 243 | const value = inputEl.value.trim(); 244 | 245 | // Allow empty value to reset to default 246 | if (!value) { 247 | this.plugin.settings.apiUrl = DEFAULT_SETTINGS.apiUrl; 248 | inputEl.value = this.plugin.settings.apiUrl; 249 | await this.plugin.saveSettings(); 250 | return; 251 | } 252 | 253 | try { 254 | const url = new URL(value); 255 | 256 | // Check protocol 257 | if (!['http:', 'https:'].includes(url.protocol)) { 258 | throw new Error('URL must use http or https protocol'); 259 | } 260 | 261 | // Remove trailing slash for consistency 262 | const normalizedUrl = value.replace(/\/$/, ''); 263 | 264 | this.plugin.settings.apiUrl = normalizedUrl; 265 | inputEl.value = normalizedUrl; // Normalize the format 266 | await this.plugin.saveSettings(); 267 | } catch (error) { 268 | new Notice(`Invalid API URL: ${error.message}`); 269 | this.plugin.settings.apiUrl = DEFAULT_SETTINGS.apiUrl; 270 | inputEl.value = this.plugin.settings.apiUrl; 271 | await this.plugin.saveSettings(); 272 | } 273 | }); 274 | 275 | return text; 276 | }); 277 | } 278 | } -------------------------------------------------------------------------------- /nordgen/src/nord_config_generator/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import asyncio 4 | import json 5 | import base64 6 | import time 7 | from typing import List, Tuple, Optional, Dict, Any 8 | from dataclasses import dataclass 9 | from pathlib import Path 10 | from math import radians, sin, cos, asin, sqrt 11 | from functools import partial 12 | from concurrent.futures import ThreadPoolExecutor 13 | from datetime import datetime 14 | 15 | import aiohttp 16 | import aiofiles 17 | 18 | from .ui import ConsoleManager 19 | 20 | @dataclass 21 | class Server: 22 | name: str 23 | hostname: str 24 | station: str 25 | load: int 26 | country: str 27 | country_code: str 28 | city: str 29 | latitude: float 30 | longitude: float 31 | public_key: str 32 | distance: float = 0.0 33 | 34 | @dataclass 35 | class UserPreferences: 36 | dns: str = "103.86.96.100" 37 | use_ip_for_endpoint: bool = False 38 | persistent_keepalive: int = 25 39 | 40 | @dataclass 41 | class GenerationStats: 42 | total_configs: int = 0 43 | best_configs: int = 0 44 | 45 | class NordVpnApiClient: 46 | NORD_API_BASE_URL = "https://api.nordvpn.com/v1" 47 | LOCATION_API_URL = "https://ipinfo.io/json" 48 | 49 | def __init__(self, console_manager: ConsoleManager): 50 | self._console = console_manager 51 | self._session: Optional[aiohttp.ClientSession] = None 52 | 53 | async def __aenter__(self): 54 | self._session = aiohttp.ClientSession() 55 | return self 56 | 57 | async def __aexit__(self, exc_type, exc_val, exc_tb): 58 | if self._session: 59 | await self._session.close() 60 | 61 | async def get_private_key(self, token: str) -> Optional[str]: 62 | auth_header = base64.b64encode(f'token:{token}'.encode()).decode() 63 | url = f"{self.NORD_API_BASE_URL}/users/services/credentials" 64 | headers = {'Authorization': f'Basic {auth_header}'} 65 | data = await self._get(url, headers=headers) 66 | if isinstance(data, dict): 67 | return data.get('nordlynx_private_key') 68 | return None 69 | 70 | async def get_all_servers(self) -> List[Dict[str, Any]]: 71 | url = f"{self.NORD_API_BASE_URL}/servers" 72 | params = {'limit': 16384, 'filters[servers_technologies][identifier]': 'wireguard_udp'} 73 | data = await self._get(url, params=params) 74 | return data if isinstance(data, list) else [] 75 | 76 | async def get_user_geolocation(self) -> Optional[Tuple[float, float]]: 77 | data = await self._get(self.LOCATION_API_URL) 78 | if not isinstance(data, dict): 79 | return None 80 | try: 81 | lat, lon = data.get('loc', '').split(',') 82 | return float(lat), float(lon) 83 | except (ValueError, IndexError): 84 | self._console.print_message("error", "Could not parse location data.") 85 | return None 86 | 87 | async def _get(self, url: str, **kwargs) -> Optional[Any]: 88 | if not self._session: 89 | return None 90 | try: 91 | async with self._session.get(url, **kwargs) as response: 92 | response.raise_for_status() 93 | return await response.json() 94 | except (aiohttp.ClientError, json.JSONDecodeError) as e: 95 | self._console.print_message("error", f"API request failed for {url}: {e}") 96 | return None 97 | 98 | class ConfigurationOrchestrator: 99 | CONCURRENT_LIMIT = 200 100 | _path_sanitizer = str.maketrans('', '', '<>:"/\\|?*\0') 101 | 102 | def __init__(self, private_key: str, preferences: UserPreferences, console_manager: ConsoleManager, api_client: NordVpnApiClient): 103 | self._private_key = private_key 104 | self._preferences = preferences 105 | self._console = console_manager 106 | self._api_client = api_client 107 | self._output_dir = Path(f'nordvpn_configs_{datetime.now().strftime("%Y%m%d_%H%M%S")}') 108 | self._semaphore = asyncio.Semaphore(self.CONCURRENT_LIMIT) 109 | self.stats = GenerationStats() 110 | 111 | async def generate(self) -> Optional[Path]: 112 | user_location, all_servers_data = await self._fetch_remote_data() 113 | if not user_location or not all_servers_data: 114 | return None 115 | 116 | processed_servers = await self._process_server_data(all_servers_data, user_location) 117 | 118 | unique_servers = {} 119 | for s in processed_servers: 120 | if s.name not in unique_servers: 121 | unique_servers[s.name] = s 122 | processed_servers = list(unique_servers.values()) 123 | 124 | sorted_servers = sorted(processed_servers, key=lambda s: (s.load, s.distance)) 125 | best_servers_by_location = self._get_best_servers(sorted_servers) 126 | 127 | self._output_dir.mkdir(exist_ok=True) 128 | servers_info = self._build_servers_info(sorted_servers) 129 | 130 | await self._save_all_configurations(sorted_servers, best_servers_by_location, servers_info) 131 | return self._output_dir 132 | 133 | async def _fetch_remote_data(self) -> Tuple[Optional[Tuple[float, float]], List[Dict[str, Any]]]: 134 | with self._console.create_progress_bar() as progress: 135 | task = progress.add_task("Fetching remote data...", total=2) 136 | user_location, all_servers_data = await asyncio.gather( 137 | self._api_client.get_user_geolocation(), 138 | self._api_client.get_all_servers() 139 | ) 140 | progress.update(task, advance=2) 141 | return user_location, all_servers_data 142 | 143 | async def _process_server_data(self, all_servers_data: List[Dict[str, Any]], user_location: Tuple[float, float]) -> List[Server]: 144 | loop = asyncio.get_running_loop() 145 | parse_func = partial(self._parse_server_data, user_location=user_location) 146 | with ThreadPoolExecutor(max_workers=min(32, (os.cpu_count() or 1) + 4)) as executor: 147 | tasks = [loop.run_in_executor(executor, parse_func, s) for s in all_servers_data] 148 | processed_servers = await asyncio.gather(*tasks) 149 | return [server for server in processed_servers if server] 150 | 151 | def _get_best_servers(self, sorted_servers: List[Server]) -> Dict[Tuple[str, str], Server]: 152 | best = {} 153 | for server in sorted_servers: 154 | key = (server.country, server.city) 155 | if key not in best or server.load < best[key].load: 156 | best[key] = server 157 | return best 158 | 159 | def _build_servers_info(self, sorted_servers: List[Server]) -> Dict: 160 | info = {} 161 | for server in sorted_servers: 162 | country_info = info.setdefault(server.country, {}) 163 | city_info = country_info.setdefault(server.city, {"distance": int(server.distance), "servers": []}) 164 | city_info["servers"].append((server.name, server.load)) 165 | return info 166 | 167 | async def _save_all_configurations(self, sorted_servers: List[Server], best_servers: Dict, servers_info: Dict): 168 | used_paths: Dict[str, int] = {} 169 | 170 | with self._console.create_progress_bar(transient=False) as progress: 171 | self.stats.total_configs = len(sorted_servers) 172 | self.stats.best_configs = len(best_servers) 173 | 174 | task_all = progress.add_task("Generating standard configs...", total=self.stats.total_configs) 175 | task_best = progress.add_task("Generating optimized configs...", total=self.stats.best_configs) 176 | 177 | save_tasks = [] 178 | save_tasks.extend(self._create_batch_save_tasks(sorted_servers, 'configs', progress, task_all, used_paths)) 179 | save_tasks.extend(self._create_batch_save_tasks(list(best_servers.values()), 'best_configs', progress, task_best, used_paths)) 180 | 181 | await asyncio.gather(*save_tasks) 182 | async with aiofiles.open(self._output_dir / 'servers.json', 'w') as f: 183 | await f.write(json.dumps(servers_info, indent=2, separators=(',', ':'), ensure_ascii=False)) 184 | 185 | def _create_batch_save_tasks(self, servers: List[Server], subfolder: str, progress, task_id, used_paths: Dict[str, int]): 186 | tasks = [] 187 | for server in servers: 188 | country_clean = self._sanitize_path_part(server.country) 189 | city_clean = self._sanitize_path_part(server.city) 190 | dir_path = self._output_dir / subfolder / country_clean / city_clean 191 | 192 | base_filename = self._extract_base_filename(server) 193 | rel_path = f"{subfolder}/{country_clean}/{city_clean}/{base_filename}" 194 | 195 | if rel_path in used_paths: 196 | idx = used_paths[rel_path] 197 | if idx == 0: idx = 1 198 | 199 | base_path_no_ext = rel_path[:-5] 200 | base_name_no_ext = base_filename[:-5] 201 | 202 | while True: 203 | new_rel = f"{base_path_no_ext}_{idx}.conf" 204 | if new_rel not in used_paths: 205 | used_paths[rel_path] = idx + 1 206 | used_paths[new_rel] = 0 207 | filename = f"{base_name_no_ext}_{idx}.conf" 208 | break 209 | idx += 1 210 | else: 211 | filename = base_filename 212 | used_paths[rel_path] = 0 213 | 214 | config_str = self._generate_wireguard_config_string(server, self._preferences, self._private_key) 215 | tasks.append(self._save_config_file(config_str, dir_path, filename, progress, task_id)) 216 | return tasks 217 | 218 | async def _save_config_file(self, config_string: str, path: Path, filename: str, progress, task_id): 219 | path.mkdir(parents=True, exist_ok=True) 220 | async with self._semaphore: 221 | async with aiofiles.open(path / filename, 'w') as f: 222 | await f.write(config_string) 223 | progress.update(task_id, advance=1) 224 | 225 | @staticmethod 226 | def _extract_base_filename(server: Server) -> str: 227 | s = server.name 228 | num = "" 229 | for i in range(len(s) - 1, -1, -1): 230 | if s[i].isdigit(): 231 | start = i 232 | while start >= 0 and s[start].isdigit(): 233 | start -= 1 234 | num = s[start+1 : i+1] 235 | break 236 | 237 | if not num: 238 | fallback = f"wg{server.station.replace('.', '')}" 239 | return f"{fallback[:15]}.conf" 240 | 241 | base = f"{server.country_code}{num}" 242 | return f"{base[:15]}.conf" 243 | 244 | @staticmethod 245 | def _generate_wireguard_config_string(server: Server, preferences: UserPreferences, private_key: str) -> str: 246 | endpoint = server.station if preferences.use_ip_for_endpoint else server.hostname 247 | return f"[Interface]\nPrivateKey = {private_key}\nAddress = 10.5.0.2/16\nDNS = {preferences.dns}\n\n[Peer]\nPublicKey = {server.public_key}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = {endpoint}:51820\nPersistentKeepalive = {preferences.persistent_keepalive}" 248 | 249 | @staticmethod 250 | def _parse_server_data(server_data: Dict[str, Any], user_location: Tuple[float, float]) -> Optional[Server]: 251 | try: 252 | location = server_data['locations'][0] 253 | country_info = location['country'] 254 | 255 | public_key = next( 256 | m['value'] for t in server_data['technologies'] 257 | if t['identifier'] == 'wireguard_udp' 258 | for m in t['metadata'] if m['name'] == 'public_key' 259 | ) 260 | distance = ConfigurationOrchestrator._calculate_distance( 261 | user_location[0], user_location[1], location['latitude'], location['longitude'] 262 | ) 263 | return Server( 264 | name=server_data['name'], hostname=server_data['hostname'], 265 | station=server_data['station'], load=int(server_data.get('load', 0)), 266 | country=country_info['name'], country_code=country_info['code'].lower(), 267 | city=country_info.get('city', {}).get('name', 'Unknown'), 268 | latitude=location['latitude'], longitude=location['longitude'], 269 | public_key=public_key, distance=distance 270 | ) 271 | except (KeyError, IndexError, StopIteration): 272 | return None 273 | 274 | @staticmethod 275 | def _calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: 276 | lon1_rad, lat1_rad, lon2_rad, lat2_rad = map(radians, [lon1, lat1, lon2, lat2]) 277 | dlon = lon2_rad - lon1_rad 278 | dlat = lat2_rad - lat1_rad 279 | a = sin(dlat / 2)**2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2)**2 280 | c = 2 * asin(sqrt(a)) 281 | return c * 6371 282 | 283 | @classmethod 284 | def _sanitize_path_part(cls, part: str) -> str: 285 | return part.lower().replace(' ', '_').replace('#', '').translate(cls._path_sanitizer) 286 | 287 | class Application: 288 | def __init__(self): 289 | self._console = ConsoleManager() 290 | 291 | async def run(self, args: List[str]): 292 | async with NordVpnApiClient(self._console) as api_client: 293 | try: 294 | if not args: 295 | await self._run_generate_command(api_client) 296 | elif args[0] == "get-key" and len(args) == 1: 297 | await self._run_get_key_command(api_client) 298 | else: 299 | command = " ".join(args) 300 | self._console.print_message("error", f"Unknown command or invalid arguments: '{command}'.") 301 | self._console.print_message("info", "Usage: nordgen | nordgen get-key") 302 | except Exception as e: 303 | self._console.print_message("error", f"An unrecoverable error occurred: {e}") 304 | 305 | async def _run_get_key_command(self, api_client: NordVpnApiClient): 306 | self._console.clear() 307 | self._console.print_title() 308 | private_key = await self._get_validated_private_key(api_client) 309 | if private_key: 310 | self._console.display_key(private_key) 311 | 312 | async def _run_generate_command(self, api_client: NordVpnApiClient): 313 | self._console.clear() 314 | self._console.print_title() 315 | private_key = await self._get_validated_private_key(api_client) 316 | if not private_key: 317 | return 318 | 319 | preferences = self._collect_user_preferences() 320 | 321 | self._console.clear() 322 | 323 | start_time = time.time() 324 | orchestrator = ConfigurationOrchestrator(private_key, preferences, self._console, api_client) 325 | output_dir = await orchestrator.generate() 326 | elapsed_time = time.time() - start_time 327 | 328 | if output_dir: 329 | self._console.display_summary(output_dir, orchestrator.stats, elapsed_time) 330 | else: 331 | self._console.print_message("error", "Process failed. Check logs for details.") 332 | 333 | def _collect_user_preferences(self) -> UserPreferences: 334 | defaults = UserPreferences() 335 | user_input = self._console.get_preferences(defaults) 336 | 337 | dns_input = user_input.get("dns") 338 | if dns_input: 339 | parts = dns_input.split('.') 340 | if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts): 341 | defaults.dns = dns_input 342 | 343 | use_ip = user_input.get("endpoint_type", "").lower() == 'y' 344 | 345 | keepalive_input = user_input.get("keepalive") 346 | if keepalive_input and keepalive_input.isdigit(): 347 | keepalive_val = int(keepalive_input) 348 | if 15 <= keepalive_val <= 120: 349 | defaults.persistent_keepalive = keepalive_val 350 | 351 | return UserPreferences( 352 | dns=defaults.dns, 353 | use_ip_for_endpoint=use_ip, 354 | persistent_keepalive=defaults.persistent_keepalive 355 | ) 356 | 357 | async def _get_validated_private_key(self, api_client: NordVpnApiClient) -> Optional[str]: 358 | token = self._console.get_user_input("Please enter your NordVPN access token: ", is_secret=True) 359 | is_hex = len(token) == 64 and all(c in '0123456789abcdefABCDEF' for c in token) 360 | if not is_hex: 361 | self._console.print_message("error", "Invalid token format.") 362 | return None 363 | 364 | with self._console.create_progress_bar() as progress: 365 | task = progress.add_task("Validating token...", total=1) 366 | private_key = await api_client.get_private_key(token) 367 | progress.update(task, advance=1) 368 | 369 | if private_key: 370 | self._console.print_message("success", "Token validated successfully.") 371 | return private_key 372 | else: 373 | self._console.print_message("error", "Token is invalid or could not be verified.") 374 | return None 375 | 376 | def cli_entry_point(): 377 | try: 378 | app = Application() 379 | asyncio.run(app.run(sys.argv[1:])) 380 | except KeyboardInterrupt: 381 | print("\nProcess interrupted by user.") 382 | 383 | if __name__ == "__main__": 384 | cli_entry_point() -------------------------------------------------------------------------------- /Obsidian-plugin/src/view.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf, ButtonComponent, DropdownComponent, setIcon, Notice, Modal } from 'obsidian'; 2 | import { VIEW_TYPE_NORDVPN } from './constants'; 3 | import NordVPNPlugin from './main'; 4 | import { ServerGroup, ServerData, ConfigServerInfo } from './types'; 5 | 6 | export class NordVPNView extends ItemView { 7 | private plugin: NordVPNPlugin; 8 | private privateKey: string | null = null; 9 | private servers: ServerGroup | null = null; 10 | private selectedCountry: string = 'All Countries'; 11 | private selectedCity: string = 'All Cities'; 12 | private sortByLoad: boolean = false; 13 | private sortAZ: boolean = true; 14 | private sortLoadReverse: boolean = false; 15 | private sortAZReverse: boolean = false; 16 | private observer: IntersectionObserver | null = null; 17 | private currentPage: number = 0; 18 | private readonly serversPerPage: number = 40; 19 | private citySelect: DropdownComponent | null = null; 20 | 21 | constructor(leaf: WorkspaceLeaf, plugin: NordVPNPlugin) { 22 | super(leaf); 23 | this.plugin = plugin; 24 | } 25 | 26 | public updatePrivateKey() { 27 | try { 28 | this.privateKey = this.plugin.getPrivateKey(); 29 | } catch (error) { 30 | this.privateKey = null; 31 | console.log('No private key set, continuing without it'); 32 | } 33 | } 34 | 35 | getViewType(): string { 36 | return VIEW_TYPE_NORDVPN; 37 | } 38 | 39 | getDisplayText(): string { 40 | return 'NordVPN Config Generator'; 41 | } 42 | 43 | async onOpen() { 44 | await this.initializeView(); 45 | } 46 | 47 | async onClose() { 48 | this.observer?.disconnect(); 49 | } 50 | 51 | private async initializeView() { 52 | const container = this.containerEl.children[1] as HTMLElement; 53 | container.empty(); 54 | container.addClass('nordvpn-view'); 55 | 56 | try { 57 | // Try to get private key if available, but don't require it 58 | try { 59 | this.privateKey = this.plugin.getPrivateKey(); 60 | } catch (error) { 61 | // Just log it, don't show error or return 62 | console.log('No private key set, continuing without it'); 63 | } 64 | 65 | // Get servers 66 | this.servers = await this.plugin.getServers(); 67 | if (!this.servers) { 68 | this.showError('Failed to fetch server list.'); 69 | return; 70 | } 71 | 72 | // Create controls 73 | this.createControls(container); 74 | 75 | // Create server grid 76 | const gridEl = container.createEl('div', { cls: 'nordvpn-server-grid' }); 77 | 78 | // Initialize intersection observer for lazy loading 79 | this.setupLazyLoading(gridEl); 80 | 81 | // Initial render 82 | this.renderServers(); 83 | 84 | } catch (error) { 85 | this.showError(`An error occurred: ${error.message}`); 86 | } 87 | } 88 | 89 | private createControls(container: HTMLElement) { 90 | // Create top bar 91 | const topBar = container.createEl('div', { cls: 'nordvpn-top-bar' }); 92 | 93 | // Left side - Country and City dropdowns 94 | const selectorsEl = topBar.createEl('div', { cls: 'nordvpn-selectors' }); 95 | selectorsEl.setAttribute('data-all-countries', 'true'); 96 | 97 | // Country selector 98 | const countrySelect = new DropdownComponent(selectorsEl) 99 | .addOption('All Countries', 'All Countries'); 100 | 101 | if (this.servers) { 102 | Object.keys(this.servers).sort().forEach(country => { 103 | countrySelect.addOption(country, this.formatDisplayName(country)); 104 | }); 105 | } 106 | 107 | // Create container for city dropdown 108 | const cityContainer = selectorsEl.createDiv({ cls: 'city-select' }); 109 | 110 | // City selector 111 | this.citySelect = new DropdownComponent(cityContainer) 112 | .addOption('All Cities', 'All Cities'); 113 | 114 | countrySelect.setValue(this.selectedCountry) 115 | .onChange((value) => { 116 | this.selectedCountry = value; 117 | selectorsEl.setAttribute('data-all-countries', value === 'All Countries' ? 'true' : 'false'); 118 | 119 | if (value === 'All Countries') { 120 | this.selectedCity = 'All Cities'; 121 | } else { 122 | const cities = this.servers?.[value]; 123 | if (cities && Object.keys(cities).length === 1) { 124 | this.selectedCity = Object.keys(cities)[0]; 125 | } else { 126 | this.selectedCity = 'All Cities'; 127 | } 128 | } 129 | 130 | this.updateCityDropdown(); 131 | this.renderServers(); 132 | this.updateServerCount(countEl); 133 | }); 134 | 135 | this.updateCityDropdown(); 136 | 137 | this.citySelect.onChange((value) => { 138 | this.selectedCity = value; 139 | this.renderServers(); 140 | this.updateServerCount(countEl); 141 | }); 142 | 143 | // Right side - Sort controls and count 144 | const controlsEl = topBar.createEl('div', { cls: 'nordvpn-controls' }); 145 | 146 | // Sort buttons 147 | const loadSortBtn = new ButtonComponent(controlsEl) 148 | .setButtonText('By Load') 149 | .onClick(() => { 150 | if (this.sortByLoad) { 151 | this.sortLoadReverse = !this.sortLoadReverse; 152 | } else { 153 | this.sortByLoad = true; 154 | this.sortAZ = false; 155 | this.sortLoadReverse = false; 156 | } 157 | this.updateSortButtons(loadSortBtn, azSortBtn); 158 | this.renderServers(); 159 | }); 160 | 161 | const azSortBtn = new ButtonComponent(controlsEl) 162 | .setButtonText('A-Z') 163 | .onClick(() => { 164 | if (this.sortAZ) { 165 | this.sortAZReverse = !this.sortAZReverse; 166 | } else { 167 | this.sortAZ = true; 168 | this.sortByLoad = false; 169 | this.sortAZReverse = false; 170 | } 171 | this.updateSortButtons(loadSortBtn, azSortBtn); 172 | this.renderServers(); 173 | }); 174 | 175 | // Server count 176 | const countEl = controlsEl.createEl('div', { 177 | cls: 'server-count', 178 | attr: { style: 'margin-left: var(--size-4-2);' } 179 | }); 180 | this.updateServerCount(countEl); 181 | 182 | // Initial sort button states 183 | this.updateSortButtons(loadSortBtn, azSortBtn); 184 | } 185 | 186 | private updateSortButtons(loadBtn: ButtonComponent, azBtn: ButtonComponent) { 187 | loadBtn.buttonEl.removeClass('mod-cta', 'mod-warning'); 188 | azBtn.buttonEl.removeClass('mod-cta', 'mod-warning'); 189 | 190 | if (this.sortByLoad) { 191 | loadBtn.buttonEl.addClass(this.sortLoadReverse ? 'mod-warning' : 'mod-cta'); 192 | } else if (this.sortAZ) { 193 | azBtn.buttonEl.addClass(this.sortAZReverse ? 'mod-warning' : 'mod-cta'); 194 | } 195 | } 196 | 197 | private updateCityDropdown() { 198 | if (!this.servers || !this.citySelect) return; 199 | 200 | const currentValue = this.citySelect.getValue(); 201 | const dropdownEl = this.citySelect.selectEl; 202 | 203 | while (dropdownEl.firstChild) { 204 | dropdownEl.removeChild(dropdownEl.firstChild); 205 | } 206 | 207 | if (this.selectedCountry !== 'All Countries') { 208 | const cities = this.servers[this.selectedCountry]; 209 | if (cities) { 210 | const cityList = Object.keys(cities).sort(); 211 | 212 | if (cityList.length > 1) { 213 | const allCitiesOption = document.createElement('option'); 214 | allCitiesOption.value = 'All Cities'; 215 | allCitiesOption.text = 'All Cities'; 216 | dropdownEl.appendChild(allCitiesOption); 217 | } 218 | 219 | cityList.forEach(city => { 220 | const option = document.createElement('option'); 221 | option.value = city; 222 | option.text = this.formatDisplayName(city); 223 | dropdownEl.appendChild(option); 224 | }); 225 | 226 | if (cityList.length === 1) { 227 | this.selectedCity = cityList[0]; 228 | this.citySelect.setValue(cityList[0]); 229 | } else { 230 | const options = Array.from(dropdownEl.options).map(opt => opt.value); 231 | this.citySelect.setValue(options.includes(currentValue) ? currentValue : 'All Cities'); 232 | } 233 | } 234 | } 235 | } 236 | 237 | private getFilteredServers(): ServerData[] { 238 | if (!this.servers) return []; 239 | 240 | let filtered: ServerData[] = []; 241 | 242 | Object.entries(this.servers).forEach(([country, cities]) => { 243 | if (this.selectedCountry === 'All Countries' || country === this.selectedCountry) { 244 | Object.entries(cities).forEach(([city, servers]) => { 245 | if (this.selectedCity === 'All Cities' || city === this.selectedCity) { 246 | servers.forEach(server => { 247 | filtered.push({ country, city, server }); 248 | }); 249 | } 250 | }); 251 | } 252 | }); 253 | 254 | if (this.sortByLoad) { 255 | filtered.sort((a, b) => { 256 | const comparison = a.server.load - b.server.load; 257 | return this.sortLoadReverse ? -comparison : comparison; 258 | }); 259 | } else if (this.sortAZ) { 260 | filtered.sort((a, b) => { 261 | const comparison = a.server.name.localeCompare(b.server.name); 262 | return this.sortAZReverse ? -comparison : comparison; 263 | }); 264 | } 265 | 266 | return filtered; 267 | } 268 | 269 | private async renderServers() { 270 | if (!this.servers) return; 271 | 272 | const gridEl = this.containerEl.querySelector('.nordvpn-server-grid') as HTMLElement; 273 | if (!gridEl) return; 274 | 275 | gridEl.empty(); 276 | this.currentPage = 0; 277 | 278 | const filtered = this.getFilteredServers(); 279 | const start = 0; 280 | const end = Math.min(this.serversPerPage, filtered.length); 281 | 282 | this.renderServerBatch(gridEl, filtered, start, end); 283 | 284 | if (end < filtered.length) { 285 | const sentinel = gridEl.createEl('div', { cls: 'scroll-sentinel' }); 286 | this.observer?.observe(sentinel); 287 | } 288 | } 289 | 290 | private async loadMoreServers() { 291 | const filtered = this.getFilteredServers(); 292 | const gridEl = this.containerEl.querySelector('.nordvpn-server-grid') as HTMLElement; 293 | if (!gridEl) return; 294 | 295 | const start = (this.currentPage + 1) * this.serversPerPage; 296 | const end = Math.min(start + this.serversPerPage, filtered.length); 297 | 298 | if (start < filtered.length) { 299 | const oldSentinel = gridEl.querySelector('.scroll-sentinel'); 300 | if (oldSentinel) { 301 | oldSentinel.remove(); 302 | } 303 | 304 | this.currentPage++; 305 | this.renderServerBatch(gridEl, filtered, start, end); 306 | 307 | if (end < filtered.length) { 308 | const sentinel = gridEl.createEl('div', { 309 | cls: 'scroll-sentinel', 310 | attr: { style: 'height: 10px; margin: 10px 0;' } 311 | }); 312 | this.observer?.observe(sentinel); 313 | } 314 | } 315 | } 316 | 317 | private renderServerBatch(containerEl: HTMLElement, servers: ServerData[], start: number, end: number) { 318 | for (let i = start; i < end; i++) { 319 | const { country, city, server } = servers[i]; 320 | this.createServerCard(containerEl, country, city, server); 321 | } 322 | } 323 | 324 | private createServerCard(containerEl: HTMLElement, country: string, city: string, server: { name: string; load: number }) { 325 | const cardEl = containerEl.createEl('div', { 326 | cls: 'server-card-container', 327 | attr: { style: 'margin: 0.25rem; flex: 1 1 250px; min-width: 250px; max-width: calc(50% - 0.5rem);' } 328 | }); 329 | 330 | const cardContent = cardEl.createEl('div', { cls: 'server-card' }); 331 | 332 | // Info on the left 333 | const infoEl = cardContent.createEl('div', { cls: 'server-card-info' }); 334 | infoEl.createEl('div', { 335 | cls: 'server-card-name', 336 | text: this.formatDisplayName(server.name) 337 | }); 338 | 339 | const descEl = infoEl.createEl('div', { cls: 'server-card-description' }); 340 | descEl.createEl('span', { 341 | cls: 'server-card-location', 342 | text: `${this.formatDisplayName(city)}, ${this.formatDisplayName(country)}` 343 | }); 344 | 345 | descEl.createSpan({ 346 | cls: `server-card-load ${server.load > 70 ? 'mod-error' : server.load > 40 ? 'mod-warning' : 'mod-success'}`, 347 | text: ` • ${server.load}%` 348 | }); 349 | 350 | // Actions on the right 351 | const controlEl = cardContent.createEl('div', { cls: 'server-card-actions' }); 352 | 353 | // Copy config button 354 | const copyBtn = new ButtonComponent(controlEl) 355 | .setIcon('copy') 356 | .setTooltip('Copy configuration'); 357 | copyBtn.buttonEl.addClass('server-card-icon-button'); 358 | copyBtn.onClick(async () => { 359 | try { 360 | // Get the latest private key before generating config 361 | this.updatePrivateKey(); 362 | const config = await this.plugin.generateConfig(this.privateKey, { 363 | country, 364 | city, 365 | name: server.name 366 | }); 367 | await navigator.clipboard.writeText(config); 368 | new Notice('Configuration copied to clipboard'); 369 | } catch (error) { 370 | new Notice(`Failed to copy configuration: ${error.message}`); 371 | } 372 | }); 373 | 374 | // Download button 375 | const downloadBtn = new ButtonComponent(controlEl) 376 | .setIcon('download') 377 | .setTooltip('Download configuration'); 378 | downloadBtn.buttonEl.addClass('server-card-icon-button'); 379 | downloadBtn.onClick(async () => { 380 | try { 381 | // Get the latest private key before saving config 382 | this.updatePrivateKey(); 383 | await this.plugin.saveConfig(this.privateKey, { 384 | country, 385 | city, 386 | name: server.name 387 | }, ''); 388 | } catch (error) { 389 | new Notice(`Failed to save configuration: ${error.message}`); 390 | } 391 | }); 392 | 393 | // QR Code button 394 | const qrBtn = new ButtonComponent(controlEl) 395 | .setIcon('qr-code') 396 | .setTooltip('Show QR code'); 397 | qrBtn.buttonEl.addClass('server-card-icon-button'); 398 | qrBtn.onClick(async () => { 399 | try { 400 | // Get the latest private key before generating QR code 401 | this.updatePrivateKey(); 402 | const qrUrl = await this.plugin.generateQRCode(this.privateKey, { 403 | country, 404 | city, 405 | name: server.name 406 | }); 407 | this.showQRCode(qrUrl, server.name); 408 | } catch (error) { 409 | new Notice(`Failed to generate QR code: ${error.message}`); 410 | } 411 | }); 412 | } 413 | 414 | private showQRCode(qrUrl: string, serverName: string) { 415 | const modal = new Modal(this.app); 416 | const { contentEl } = modal; 417 | contentEl.addClass('nordvpn-qr-modal'); 418 | 419 | contentEl.createEl('h2', { text: `QR Code for ${serverName}` }); 420 | 421 | contentEl.createEl('p', { 422 | text: 'Scan this code with your mobile device to import the WireGuard configuration.', 423 | cls: 'nordvpn-qr-description' 424 | }); 425 | 426 | contentEl.createEl('img', { 427 | attr: { 428 | src: qrUrl, 429 | alt: `WireGuard Configuration QR Code for ${serverName}` 430 | } 431 | }); 432 | 433 | modal.onClose = () => { 434 | URL.revokeObjectURL(qrUrl); 435 | contentEl.empty(); 436 | }; 437 | 438 | modal.open(); 439 | } 440 | 441 | private showError(message: string) { 442 | const container = this.containerEl.children[1]; 443 | container.empty(); 444 | container.createEl('div', { 445 | cls: 'nordvpn-status error', 446 | text: message 447 | }); 448 | } 449 | 450 | private setupLazyLoading(gridEl: HTMLElement) { 451 | this.observer?.disconnect(); 452 | 453 | this.observer = new IntersectionObserver((entries) => { 454 | entries.forEach(entry => { 455 | if (entry.isIntersecting) { 456 | this.loadMoreServers(); 457 | } 458 | }); 459 | }, { 460 | root: gridEl, 461 | threshold: 0.1, 462 | rootMargin: '100px' 463 | }); 464 | 465 | const sentinel = gridEl.createEl('div', { 466 | cls: 'scroll-sentinel', 467 | attr: { style: 'height: 10px; margin: 10px 0;' } 468 | }); 469 | this.observer.observe(sentinel); 470 | } 471 | 472 | private updateServerCount(countEl: HTMLElement) { 473 | if (!this.servers) return; 474 | 475 | const filtered = this.getFilteredServers(); 476 | const total = filtered.length; 477 | 478 | countEl.setText(`${total} server${total !== 1 ? 's' : ''}`); 479 | } 480 | 481 | private formatDisplayName(name: string): string { 482 | return name.replace(/_/g, ' '); 483 | } 484 | } --------------------------------------------------------------------------------