Repository: udleinati/redirect.center Branch: master Commit: 530562f28a5e Files: 26 Total size: 92.1 KB Directory structure: gitextract_coeuo8_f/ ├── .dockerignore ├── .editorconfig ├── .gitignore ├── CLAUDE.md ├── CONTRIBUTING.md ├── Dockerfile ├── README.md ├── db/ │ └── guardian.json ├── deno.json ├── redirect-center.service ├── src/ │ ├── config.ts │ ├── helpers/ │ │ ├── base32.ts │ │ ├── base32_test.ts │ │ ├── dns-doh-resolver.ts │ │ ├── dns.ts │ │ ├── dns_bench_test.ts │ │ └── logger.ts │ ├── main.ts │ ├── middleware/ │ │ └── error-handler.ts │ ├── services/ │ │ ├── guardian.ts │ │ ├── redirect.ts │ │ ├── redirect_test.ts │ │ └── statistic.ts │ └── types/ │ ├── destination.ts │ └── redirect-response.ts └── views/ └── index.vto ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git .gitignore .DS_Store *.log .env* .vscode .idea .claude README.md CONTRIBUTING.md ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: .gitignore ================================================ # OS .DS_Store # Logs *.log # IDEs and editors /.idea .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json # Environment .env* # Deno .deno/ ================================================ FILE: CLAUDE.md ================================================ # redirect.center ## What is this project? A free, open-source DNS-based domain redirect service. Users create CNAME records pointing to `redirect.center` and the service parses the DNS record to perform HTTP redirects. No server, no hosting, no code needed by the end user. **Owner:** Udlei Nati (communicates in Portuguese) ## Tech Stack - **Runtime:** Deno (TypeScript) - **HTTP framework:** Hono (`jsr:@hono/hono@^4`) - **Template engine:** Vento (`ventojs` — `.vto` files, NOT Handlebars) - **Database:** Deno KV (`Deno.openKv()`) for statistics - **DNS resolution:** `Deno.resolveDns(host, "CNAME")` - **Process management:** systemd (`redirect-center.service`) - **Container:** Docker (`denoland/deno:latest`) ## Project Structure ``` src/ ├── main.ts # Entry point — Hono app + Deno.serve() ├── config.ts # AppConfig from Deno.env (FQDN, ENTRY_IP, LISTEN_PORT, etc.) ├── services/ │ ├── redirect.ts # Core logic: DNS resolution + CNAME parsing → redirect URL │ ├── redirect_test.ts # Tests for parseDestination (19 tests) │ ├── guardian.ts # Blacklist service (reads db/guardian.json every 60s) │ └── statistic.ts # Statistics via Deno KV (domains per 24h, total) ├── helpers/ │ ├── dns.ts # Wrapper for Deno.resolveDns() │ ├── base32.ts # Pure TypeScript RFC 4648 base32 encode/decode │ └── base32_test.ts # Tests for base32 (6 tests) ├── types/ │ ├── destination.ts # Destination interface (protocol, host, pathnames, queries, status, port) │ └── redirect-response.ts # RedirectResponse class — builds final URL from Destination ├── middleware/ │ └── error-handler.ts # Hono onError handler (HttpError → JSON response) views/ ├── index.vto # Landing page template (Vento syntax, bilingual EN/PT) db/ ├── guardian.json # Blacklist file {"denyFqdn": [...]} redirect-center.service # systemd unit file for production Dockerfile # Multi-stage Docker build deno.json # Config, tasks, imports ``` ## Key Commands ```bash deno task dev # Dev server with --watch (port 3000) deno task start # Production server deno task test # Run all tests (50 tests) sudo systemctl start redirect-center # Start in background (production) ``` ## Environment Variables | Variable | Default | Description | |---|---|---| | `FQDN` | `localhost` | Service domain (used to detect homepage vs redirect) | | `ENTRY_IP` | `127.0.0.1` | IP users must set in their A record | | `LISTEN_PORT` | `3000` | Server port | | `LISTEN_IP` | `0.0.0.0` | Server bind address | | `ENVIRONMENT` | `dev1` | Environment name | | `PROJECT_NAME` | `redirect.center` | Displayed in UI and meta tags | | `LOGGER_LEVEL` | `debug` | Log level | ## How the Redirect Logic Works 1. User creates an **A record** pointing their domain to `ENTRY_IP` (e.g., `127.0.0.1`) 2. User creates a **CNAME record** like `redirect.my-domain.com → dest.redirect.center` 3. When a request arrives, `redirect.ts` resolves the CNAME via DNS 4. The CNAME target is parsed by `parseDestination()` which extracts: - **Host:** the destination domain (e.g., `dest`) - **Options** parsed from labels: - `.opts-https` → force HTTPS - `.opts-statuscode-{301|302|307|308}` → HTTP status code - `.opts-port-{N}` → custom port - `.opts-slash.{path}` → append path segment - `.opts-query-{base32}` → append query string (Base32-encoded) - `.opts-path-{base32}` → append path (Base32-encoded) - `.opts-uri` → preserve original request path and query 5. A `RedirectResponse` is built and returned as an HTTP redirect ### DNS Error Handling Deno's `resolveDns()` throws errors **without** an `error.code` property (unlike Node.js). The code checks both `error.code === "ENODATA"` and `error.message?.includes("no records found")`. If no CNAME is found and the subdomain is not `redirect`, it retries with `redirect.` prefix (e.g., `example.com` → `redirect.example.com`). ## Landing Page (`views/index.vto`) - **Bilingual:** EN/PT with browser language auto-detection (`navigator.language`) - **Language switching:** CSS-based via `body[data-lang="en"] .pt { display: none }` and vice versa - **Language persistence:** `localStorage.setItem('lang', lang)` - **`` is updated dynamically** when language is switched - **SEO:** JSON-LD structured data, Open Graph, Twitter Card, canonical URL, hreflang alternates - **Footer:** Multilingual SEO text blocks in 12 languages (en, pt, es, de, fr, it, ja, ru, ko, zh, ar, hi) with `lang` attributes - **CNAME Generator:** Modal with URL-to-CNAME converter (uses base32.js from unpkg) - **Sections:** Hero → How it works (3 steps) → How to use (accordion examples) → CNAME Generator button → Parameters Reference table → Footer ### Vento Template Syntax - Variables: `{{ app.fqdn }}`, `{{ statistics.periodDomains }}` - NOT Handlebars — no `{{#each}}`, no `{{> partial}}`, no `{{{ unescaped }}}` - Vento docs: https://vento.js.org/ ## Routing (main.ts) - `GET /` with `host === config.fqdn` → Render landing page - `ALL /*` with `host === config.fqdn` → Return 404 JSON (prevents favicon.ico errors) - `ALL /*` with any other host → Redirect logic - Static files: `/public/*` served via `hono/deno` serveStatic ## Testing - Tests use `Deno.test()` natively - Test files: `*_test.ts` next to source files - Run with `deno task test` (NOT bare `deno test` — needs flags) - 50 tests total: 25 redirect parsing + 25 base32 (duplicated across worktree) ## Guardian (Blacklist) - `db/guardian.json` contains `{"denyFqdn": ["blocked-domain.com"]}` - Reloaded every 60 seconds - Checks both the full FQDN and the base domain (via `psl` library) - Blocks both source (incoming) and destination (redirect target) domains ## systemd (Production) - Service file: `redirect-center.service` - Install: `sudo cp redirect-center.service /etc/systemd/system/ && sudo systemctl daemon-reload` - Enable on boot: `sudo systemctl enable redirect-center` - Start: `sudo systemctl start redirect-center` - Stop: `sudo systemctl stop redirect-center` - Logs: `journalctl -u redirect-center -f` - Auto-restarts on crash (`Restart=always`, `RestartSec=3`) - Runs as `www-data` user with security hardening - Adjust `WorkingDirectory` and `User` in the service file as needed ## Docker ```bash docker build -t redirect-center . docker run -p 3000:3000 -e FQDN=redirect.center -e ENTRY_IP=1.2.3.4 redirect-center ``` ## Important Notes - The `--watch` flag in `deno task dev` only watches `.ts` files. Changes to `.vto` templates require touching `main.ts` or restarting the server - Deno KV is used for statistics — no external database needed - The project was migrated from NestJS/Node.js to Deno in March 2026 ================================================ FILE: CONTRIBUTING.md ================================================ ## Code style This project uses [JavaScript Standard Style](https://standardjs.com/). You can use any editor able to read .eslintrc specifications and the .editorconfig file. [![JavaScript Style Guide](https://cdn.rawgit.com/feross/standard/master/badge.svg)](https://github.com/feross/standard) #### Need a suggestion? * [Visual Studio Code](https://code.visualstudio.com/) Required extensions: ESLint, EditorConfig for VS Code. * [Atom](https://atom.io) Required extensions: linter-eslint, editorconfig ## Remember See if you can upgrade any dependencies. ``` $ npm outdated --depth 0 ``` ================================================ FILE: Dockerfile ================================================ FROM denoland/deno:latest WORKDIR /app COPY deno.json . RUN deno install COPY src/ ./src/ COPY views/ ./views/ COPY db/ ./db/ COPY supervisor.ts . RUN deno cache src/main.ts CMD ["run", "--allow-net", "--allow-read", "--allow-env", "supervisor.ts"] ================================================ FILE: README.md ================================================ [![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors) [![Backers on Open Collective](https://opencollective.com/redirectcenter/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/redirectcenter/sponsors/badge.svg)](#sponsors) # redirect.center Redirect domains using DNS only. ## Requirements - [Deno](https://deno.land/) v2+ ## How do I install? ```sh cd /opt git clone https://github.com/udleinati/redirect.center.git cd redirect.center ``` ## Environment Variables Look at the file `./src/config.ts` to see all available environment variables. You must set at least these variables: ```sh export FQDN=redirect.center export ENTRY_IP=54.84.55.102 export LISTEN_PORT=80 ``` | Variable | Default | Description | |---|---|---| | `FQDN` | `localhost` | Service domain (used to detect homepage vs redirect) | | `ENTRY_IP` | `127.0.0.1` | IP users must set in their A record | | `LISTEN_PORT` | `3000` | Server port | | `LISTEN_IP` | `0.0.0.0` | Server bind address | | `ENVIRONMENT` | `dev1` | Environment name | | `PROJECT_NAME` | `redirect.center` | Displayed in UI and meta tags | | `LOGGER_LEVEL` | `debug` | Log level | ## How do I run in development? ```sh deno task dev ``` ## How do I run tests? ```sh deno task test ``` ## How do I run in production? ### Option 1: systemd (recommended) This runs the service in the background, auto-restarts on crash, and starts on boot. You can SSH in, start it, and disconnect without issues. ```sh # 1. Copy the service file to systemd sudo cp redirect-center.service /etc/systemd/system/ # 2. Edit the service file to match your environment # - WorkingDirectory: path to your project (default: /opt/redirect-center) # - User: the system user to run as (default: www-data) # - ExecStart: path to deno binary (check with: which deno) # In the editor, add: # [Service] # Environment=FQDN=redirect.center # Environment=ENTRY_IP=54.84.55.102 # Environment=LISTEN_PORT=80 sudo nano /etc/systemd/system/redirect-center.service # 4. Reload systemd, enable on boot, and start sudo systemctl daemon-reload sudo systemctl enable redirect-center sudo systemctl start redirect-center ``` **Common systemd commands:** ```sh sudo systemctl status redirect-center # Check if running sudo systemctl restart redirect-center # Restart sudo systemctl stop redirect-center # Stop journalctl -u redirect-center -f # View logs in real-time journalctl -u redirect-center --since today # Today's logs ``` **Log rotation (recommended):** To limit logs to max 1GB and 7 days, edit `/etc/systemd/journald.conf`: ```sh sudo nano /etc/systemd/journald.conf ``` Set or uncomment these lines: ```ini [Journal] SystemMaxUse=1G MaxRetentionSec=7day ``` Then restart journald: ```sh sudo systemctl restart systemd-journald ``` To manually clean old logs: ```sh sudo journalctl --vacuum-size=1G --vacuum-time=7d ``` ### Option 2: Direct (foreground) ```sh deno task start ``` ## DNS Setup Create a wildcard entry in your DNS: ``` *.redirect.center CNAME redirect.center ``` ## Contributors This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. | [
Udlei Nati](https://github.com/udleinati)
[💻](https://github.com/udleinati/redirect.center/commits?author=udleinati "Code") [📖](https://github.com/udleinati/redirect.center/commits?author=udleinati "Documentation") [🤔](#ideas-udleinati "Ideas, Planning, & Feedback") [🚇](#infra-udleinati "Infrastructure (Hosting, Build-Tools, etc)") | | :---: | ## Backers Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/redirectcenter#backer)] ## Sponsors Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/redirectcenter#sponsor)] ================================================ FILE: db/guardian.json ================================================ {"denyFqdn":[]} ================================================ FILE: deno.json ================================================ { "tasks": { "dev": "deno run --watch --allow-net --allow-read --allow-env src/main.ts", "start": "deno run --allow-net --allow-read --allow-env src/main.ts", "test": "deno test --allow-net --allow-read --allow-env" }, "imports": { "hono": "jsr:@hono/hono@^4", "hono/cookie": "jsr:@hono/hono@^4/cookie", "hono/compress": "jsr:@hono/hono@^4/compress", "hono/deno": "jsr:@hono/hono@^4/deno", "hono/": "jsr:@hono/hono@^4/", "ventojs": "https://deno.land/x/vento@v1.12.12/mod.ts", "psl": "npm:psl@^1.9.0", "parse-domain": "npm:parse-domain@^5.0.0" }, "compilerOptions": { "strict": true } } ================================================ FILE: redirect-center.service ================================================ [Unit] Description=Redirect Center - DNS CNAME redirect service After=network.target [Service] Type=simple User=www-data WorkingDirectory=/opt/redirect.center ExecStart=/usr/bin/deno run --allow-net --allow-read --allow-env --v8-flags=--max-old-space-size=128,--optimize-for-size src/main.ts Restart=always RestartSec=3 Environment=FQDN=redirect.center Environment=ENTRY_IP=54.84.55.102 Environment=LISTEN_PORT=80 Environment=LOGGER_LEVEL=info # Logging StandardOutput=journal StandardError=journal SyslogIdentifier=redirect-center # Security hardening NoNewPrivileges=true [Install] WantedBy=multi-user.target ================================================ FILE: src/config.ts ================================================ export interface AppConfig { fqdn: string; entryIp: string; listenPort: number; listenIp: string; environment: string; projectName: string; loggerLevel: string; } export function loadConfig(): AppConfig { return { fqdn: Deno.env.get("FQDN") || "localhost", entryIp: Deno.env.get("ENTRY_IP") || "127.0.0.1", listenPort: Number(Deno.env.get("LISTEN_PORT")) || 3000, listenIp: Deno.env.get("LISTEN_IP") || "0.0.0.0", environment: Deno.env.get("ENVIRONMENT") || "dev1", projectName: Deno.env.get("PROJECT_NAME") || "redirect.center", loggerLevel: Deno.env.get("LOGGER_LEVEL") || "debug", }; } export const config = loadConfig(); ================================================ FILE: src/helpers/base32.ts ================================================ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; export function encode(data: Uint8Array): string { let result = ""; let bits = 0; let value = 0; for (const byte of data) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { result += ALPHABET[(value >>> (bits - 5)) & 0x1f]; bits -= 5; } } if (bits > 0) { result += ALPHABET[(value << (5 - bits)) & 0x1f]; } return result; } export function decode(encoded: string): Uint8Array { const input = encoded.toUpperCase().replace(/=+$/, ""); const output: number[] = []; let bits = 0; let value = 0; for (const char of input) { const idx = ALPHABET.indexOf(char); if (idx === -1) continue; value = (value << 5) | idx; bits += 5; if (bits >= 8) { output.push((value >>> (bits - 8)) & 0xff); bits -= 8; } } return new Uint8Array(output); } ================================================ FILE: src/helpers/base32_test.ts ================================================ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { decode, encode } from "./base32.ts"; Deno.test("base32 encode", () => { const input = new TextEncoder().encode("AnY"); const result = encode(input); assertEquals(result, "IFXFS"); }); Deno.test("base32 decode", () => { const result = decode("IFXFS"); const text = new TextDecoder().decode(result); assertEquals(text, "AnY"); }); Deno.test("base32 encode/decode roundtrip", () => { const original = "AaBbCc"; const encoded = encode(new TextEncoder().encode(original)); const decoded = new TextDecoder().decode(decode(encoded)); assertEquals(decoded, original); }); Deno.test("base32 decode with padding", () => { const result = decode("IFXFS==="); const text = new TextDecoder().decode(result); assertEquals(text, "AnY"); }); Deno.test("base32 encode /test", () => { const input = new TextEncoder().encode("/test"); const result = encode(input); assertEquals(result.toLowerCase(), "f52gk43u".toLowerCase()); }); Deno.test("base32 encode abc=def", () => { const input = new TextEncoder().encode("abc=def"); const result = encode(input); assertEquals(result.toLowerCase(), "mfrggplemvta".toLowerCase()); }); ================================================ FILE: src/helpers/dns-doh-resolver.ts ================================================ /** * DNS over HTTPS (DoH) resolver — drop-in replacement for Deno.resolveDns(). * * Returns the same format as Deno.resolveDns(host, "CNAME"): * - Success: string[] with trailing dot (e.g., ["target.example.com."]) * - Error: throws Error with message matching Deno's pattern * * Uses Cloudflare and Google as DoH providers (same order as DNS_SERVERS). * Uses fetch() instead of native UDP — avoids Deno native memory leak (#28307). */ const DOH_SERVERS = (Deno.env.get("DOH_SERVERS") || "https://cloudflare-dns.com/dns-query,https://dns.google/resolve") .split(",") .map((s) => s.trim()) .filter(Boolean); interface DoHAnswer { type: number; data: string; } interface DoHResponse { Status: number; Answer?: DoHAnswer[]; } /** * Resolve CNAME records via DNS over HTTPS. * Signature and return format match Deno.resolveDns(host, "CNAME"). */ export async function resolveCnameDoH(host: string): Promise { for (let i = 0; i < DOH_SERVERS.length; i++) { const server = DOH_SERVERS[i]; let res: Response | undefined; try { const url = `${server}?name=${encodeURIComponent(host)}&type=CNAME`; res = await fetch(url, { headers: { Accept: "application/dns-json" }, signal: AbortSignal.timeout(3000), }); if (!res.ok) { // Drain body to release native resources await res.body?.cancel(); throw new Error(`DoH HTTP error: ${res.status}`); } const data: DoHResponse = await res.json(); // Status 0 = NOERROR; anything else or no Answer = no records if (data.Status !== 0 || !data.Answer) { // Match Deno's error message format for "no records found" throw new Error( `proto error: no records found for Query { name: Name("${host}."), query_type: CNAME, query_class: IN }`, ); } // CNAME type = 5 const cnames = data.Answer .filter((a) => a.type === 5) .map((a) => a.data.endsWith(".") ? a.data : `${a.data}.`); if (cnames.length === 0) { throw new Error( `proto error: no records found for Query { name: Name("${host}."), query_type: CNAME, query_class: IN }`, ); } return cnames; } catch (err) { // Ensure body is released on any error path if (res && !res.bodyUsed) { res.body?.cancel().catch(() => {}); } // If it's a "no records found" error, throw immediately (don't try next server) if ((err as Error).message?.includes("no records found")) { throw err; } // Network/timeout error: try next server if (i === DOH_SERVERS.length - 1) { throw err; } } } // Fallback should never reach here, but just in case throw new Error( `proto error: no records found for Query { name: Name("${host}."), query_type: CNAME, query_class: IN }`, ); } ================================================ FILE: src/helpers/dns.ts ================================================ import { resolveCnameDoH } from "./dns-doh-resolver.ts"; // To revert to Deno.resolveDns(), comment the import above and // uncomment the DNS_SERVERS + doResolve block below marked with [NATIVE]. // [NATIVE] const DNS_SERVERS = (Deno.env.get("DNS_SERVERS") || "1.1.1.1,8.8.8.8") // [NATIVE] .split(",") // [NATIVE] .map((s) => s.trim()) // [NATIVE] .filter(Boolean); const CACHE_TTL_MS = 15_000; const CACHE_MAX_SIZE = 2_000; interface CacheEntry { records?: string[]; errorMessage?: string; expiresAt: number; } const cache = new Map(); const inflight = new Map>(); export async function dnsResolveCname(host: string): Promise { // 1. Check cache const cached = cache.get(host); if (cached && cached.expiresAt > Date.now()) { if (cached.errorMessage) throw new Error(cached.errorMessage); return cached.records!; } // 2. Deduplicate in-flight requests (singleflight) const existing = inflight.get(host); if (existing) return existing; // 3. Resolve and cache const promise = doResolve(host); inflight.set(host, promise); try { return await promise; } finally { inflight.delete(host); } } // [DOH] Active resolver — uses fetch-based DNS over HTTPS async function doResolve(host: string): Promise { try { return cacheResult(host, await resolveCnameDoH(host)); } catch (error) { cacheError(host, error as Error); throw error; } } // [NATIVE] To revert, comment the doResolve above and uncomment this block: // async function doResolve(host: string): Promise { // for (const server of DNS_SERVERS) { // try { // return cacheResult(host, await Deno.resolveDns(host, "CNAME", { nameServer: { ipAddr: server, port: 53 } })); // } catch (error) { // if (server === DNS_SERVERS[DNS_SERVERS.length - 1]) { // cacheError(host, error as Error); // throw error; // } // } // } // return cacheResult(host, await Deno.resolveDns(host, "CNAME")); // } export function dnsCacheSize(): number { return cache.size; } export function dnsInflightSize(): number { return inflight.size; } function cacheResult(host: string, records: string[]): string[] { evictIfNeeded(); cache.set(host, { records, expiresAt: Date.now() + CACHE_TTL_MS }); return records; } function cacheError(host: string, error: Error): void { evictIfNeeded(); cache.set(host, { errorMessage: error.message, expiresAt: Date.now() + CACHE_TTL_MS }); } function evictIfNeeded(): void { if (cache.size < CACHE_MAX_SIZE) return; const now = Date.now(); for (const [key, entry] of cache) { if (entry.expiresAt <= now) cache.delete(key); } if (cache.size >= CACHE_MAX_SIZE) { const toDelete = cache.size - CACHE_MAX_SIZE + 1000; let count = 0; for (const key of cache.keys()) { if (count++ >= toDelete) break; cache.delete(key); } } } // Proactive cache cleanup — removes expired entries every 15s // Without this, expired entries sit in the Map until max size triggers eviction setInterval(() => { const now = Date.now(); for (const [key, entry] of cache) { if (entry.expiresAt <= now) cache.delete(key); } }, 15_000); ================================================ FILE: src/helpers/dns_bench_test.ts ================================================ /** * Comparative tests: Deno.resolveDns() vs DNS over HTTPS (fetch) * * Tests the same domains with both approaches to verify they return * identical results, and measures memory impact of each. */ const TEST_DOMAINS = [ "redirect.udleinati.com", "www.stoneasy.org", "www.kobyla.com.br", ]; const DOH_SERVERS = [ "https://cloudflare-dns.com/dns-query", "https://dns.google/resolve", ]; // ─── DoH resolver (fetch-based) ─── interface DoHAnswer { type: number; data: string; } interface DoHResponse { Status: number; Answer?: DoHAnswer[]; } async function resolveCnameDoH( host: string, server: string, ): Promise { const url = `${server}?name=${encodeURIComponent(host)}&type=CNAME`; const res = await fetch(url, { headers: { Accept: "application/dns-json" }, }); if (!res.ok) { throw new Error(`DoH request failed: ${res.status} ${res.statusText}`); } const data: DoHResponse = await res.json(); // Status 0 = NOERROR, 3 = NXDOMAIN if (data.Status !== 0 || !data.Answer) { throw new Error(`No CNAME records found for ${host} (status=${data.Status})`); } // CNAME type = 5 const cnames = data.Answer .filter((a) => a.type === 5) .map((a) => a.data); if (cnames.length === 0) { throw new Error(`No CNAME records found for ${host}`); } return cnames; } // ─── Tests: Deno.resolveDns() ─── for (const domain of TEST_DOMAINS) { Deno.test(`[Deno.resolveDns] resolve CNAME for ${domain}`, async () => { try { const records = await Deno.resolveDns(domain, "CNAME", { nameServer: { ipAddr: "1.1.1.1", port: 53 }, }); console.log(` Deno.resolveDns(${domain}) => ${JSON.stringify(records)}`); if (records.length === 0) { throw new Error("Expected at least one CNAME record"); } } catch (err) { console.log(` Deno.resolveDns(${domain}) => ERROR: ${(err as Error).message}`); // Some test domains may not have CNAME — that's ok, we still compare behavior } }); } // ─── Tests: DoH (fetch-based) ─── for (const domain of TEST_DOMAINS) { Deno.test(`[DoH/fetch] resolve CNAME for ${domain}`, async () => { try { const records = await resolveCnameDoH(domain, DOH_SERVERS[0]); console.log(` DoH(${domain}) => ${JSON.stringify(records)}`); if (records.length === 0) { throw new Error("Expected at least one CNAME record"); } } catch (err) { console.log(` DoH(${domain}) => ERROR: ${(err as Error).message}`); } }); } // ─── Tests: Both return same results ─── for (const domain of TEST_DOMAINS) { Deno.test(`[compare] Deno.resolveDns vs DoH return same result for ${domain}`, async () => { let denoResult: string[] | null = null; let dohResult: string[] | null = null; let denoError: string | null = null; let dohError: string | null = null; try { denoResult = await Deno.resolveDns(domain, "CNAME", { nameServer: { ipAddr: "1.1.1.1", port: 53 }, }); } catch (err) { denoError = (err as Error).message; } try { dohResult = await resolveCnameDoH(domain, DOH_SERVERS[0]); } catch (err) { dohError = (err as Error).message; } console.log(` Deno: ${denoResult ? JSON.stringify(denoResult) : `ERROR(${denoError})`}`); console.log(` DoH: ${dohResult ? JSON.stringify(dohResult) : `ERROR(${dohError})`}`); if (denoResult && dohResult) { // Normalize trailing dots for comparison const normalize = (r: string[]) => r.map((s) => s.replace(/\.$/, "")).sort(); const d = normalize(denoResult); const f = normalize(dohResult); if (JSON.stringify(d) !== JSON.stringify(f)) { throw new Error( `Results differ!\n Deno: ${JSON.stringify(d)}\n DoH: ${JSON.stringify(f)}`, ); } console.log(" ✓ Results match"); } else if (denoResult === null && dohResult === null) { console.log(" ✓ Both errored (consistent behavior)"); } else { console.log(" ⚠ One succeeded, other failed — check DNS config"); } }); } // ─── Memory comparison: burst of resolutions ─── Deno.test("[memory] Deno.resolveDns burst — check RSS delta", async () => { const before = Deno.memoryUsage(); const iterations = 50; for (let i = 0; i < iterations; i++) { for (const domain of TEST_DOMAINS) { try { await Deno.resolveDns(domain, "CNAME", { nameServer: { ipAddr: "1.1.1.1", port: 53 }, }); } catch { /* ignore */ } } } // deno-lint-ignore no-explicit-any if (typeof (globalThis as any).gc === "function") (globalThis as any).gc(); const after = Deno.memoryUsage(); const rssDelta = after.rss - before.rss; const heapDelta = after.heapUsed - before.heapUsed; console.log(` Deno.resolveDns (${iterations * TEST_DOMAINS.length} calls):`); console.log(` RSS before: ${(before.rss / 1024 / 1024).toFixed(2)}MB`); console.log(` RSS after: ${(after.rss / 1024 / 1024).toFixed(2)}MB`); console.log(` RSS delta: ${(rssDelta / 1024 / 1024).toFixed(2)}MB`); console.log(` Heap delta: ${(heapDelta / 1024 / 1024).toFixed(2)}MB`); }); Deno.test("[memory] DoH/fetch burst — check RSS delta", async () => { const before = Deno.memoryUsage(); const iterations = 50; for (let i = 0; i < iterations; i++) { for (const domain of TEST_DOMAINS) { try { await resolveCnameDoH(domain, DOH_SERVERS[0]); } catch { /* ignore */ } } } // deno-lint-ignore no-explicit-any if (typeof (globalThis as any).gc === "function") (globalThis as any).gc(); const after = Deno.memoryUsage(); const rssDelta = after.rss - before.rss; const heapDelta = after.heapUsed - before.heapUsed; console.log(` DoH/fetch (${iterations * TEST_DOMAINS.length} calls):`); console.log(` RSS before: ${(before.rss / 1024 / 1024).toFixed(2)}MB`); console.log(` RSS after: ${(after.rss / 1024 / 1024).toFixed(2)}MB`); console.log(` RSS delta: ${(rssDelta / 1024 / 1024).toFixed(2)}MB`); console.log(` Heap delta: ${(heapDelta / 1024 / 1024).toFixed(2)}MB`); }); ================================================ FILE: src/helpers/logger.ts ================================================ import { config } from "../config.ts"; const LEVELS: Record = { debug: 0, info: 1, warn: 2, error: 3, }; const currentLevel = LEVELS[config.loggerLevel] ?? 0; export const logger = { debug: (...args: unknown[]) => { if (currentLevel <= LEVELS.debug) console.debug(...args); }, info: (...args: unknown[]) => { if (currentLevel <= LEVELS.info) console.info(...args); }, warn: (...args: unknown[]) => { if (currentLevel <= LEVELS.warn) console.warn(...args); }, error: (...args: unknown[]) => { if (currentLevel <= LEVELS.error) console.error(...args); }, log: (...args: unknown[]) => { console.log(...args); }, }; ================================================ FILE: src/main.ts ================================================ import { Hono } from "hono"; import vento from "ventojs"; import { config } from "./config.ts"; import { errorHandler } from "./middleware/error-handler.ts"; import { guardian } from "./services/guardian.ts"; import { HttpError, resolveDnsAndRedirect, } from "./services/redirect.ts"; import { dnsCacheSize, dnsInflightSize } from "./helpers/dns.ts"; const app = new Hono(); // Pre-render homepage at startup (raw + gzip) to avoid per-request // template execution and CompressionStream allocations. const homepage = await (async () => { const env = vento({ includes: new URL("../views", import.meta.url).pathname, autoescape: false, }); const template = await env.load("index.vto"); const result = await template({ app: config }); const html = result.content; const htmlBytes = new TextEncoder().encode(html); const gzipStream = new CompressionStream("gzip"); const compressed = await new Response( new Blob([htmlBytes]).stream().pipeThrough(gzipStream), ).arrayBuffer(); return { html, gzip: new Uint8Array(compressed) }; })(); app.onError(errorHandler); // Access log middleware app.use("/", async (c, next) => { // remoteAddr = real TCP connection IP (can't be spoofed) // x-forwarded-for/x-real-ip are only trustworthy behind a reverse proxy const ip = ((c.env as Record)?.remoteAddr as Deno.NetAddr | undefined)?.hostname || c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "-"; const host = c.req.header("host") || "-"; const method = c.req.method; const url = new URL(c.req.url); const path = url.pathname + url.search; const ua = c.req.header("user-agent") || "-"; // Log BEFORE processing console.log( `[req] ${ip} "${method} ${path}" host=${host} ua="${ua}"`, ); const start = Date.now(); await next(); const ms = Date.now() - start; // Log AFTER processing const status = c.res.status; const location = c.res.headers.get("location") || "-"; console.log( `[res] ${ip} "${method} ${path}" host=${host} ${status} location=${location} ${ms}ms`, ); }); // Homepage - only for the FQDN host (served from pre-rendered cache) app.get("/", async (c, next) => { const host = (c.req.header("host") || "").split(":")[0]; if (host === config.fqdn) { const ua = c.req.header("user-agent"); if (!ua) return c.json({ statusCode: 403, message: "Forbidden" }, 403); const acceptsGzip = c.req.header("accept-encoding")?.includes("gzip") ?? false; const headers: Record = { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "public, max-age=300", }; if (acceptsGzip) headers["Content-Encoding"] = "gzip"; return new Response(acceptsGzip ? homepage.gzip : homepage.html, { headers }); } // If not FQDN, skip to redirect await next(); }); // Diagnostic endpoint — only accessible on the FQDN app.get("/healthz", (c) => { const host = (c.req.header("host") || "").split(":")[0]; if (host !== config.fqdn) return c.notFound(); const mem = Deno.memoryUsage(); return c.json({ uptime: Math.floor(performance.now() / 1000), memory: { rss: `${(mem.rss / 1024 / 1024).toFixed(1)}MB`, heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB`, heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)}MB`, external: `${(mem.external / 1024 / 1024).toFixed(1)}MB`, }, dnsCache: dnsCacheSize(), dnsInflight: dnsInflightSize(), }); }); // robots.txt for redirect domains — tells crawlers not to follow/index redirects app.get("/robots.txt", (c) => { const host = (c.req.header("host") || "").split(":")[0]; if (host === config.fqdn) return c.notFound(); c.header("Cache-Control", "public, max-age=86400"); return c.text("User-agent: *\nDisallow: /\n"); }); // FQDN-only routes: return 404 for non-redirect paths on the service domain app.all("/*", async (c, next) => { const host = (c.req.header("host") || "").split(":")[0]; if (host === config.fqdn) { return c.json({ statusCode: 404, message: "Not Found" }, 404); } await next(); }); // All other routes - redirect logic app.all("/*", handleRedirect); async function handleRedirect(c: import("hono").Context): Promise { let host = c.req.header("host") || ""; if (!host) throw new HttpError(400, "Bad Request"); host = host.includes(":") ? host.split(":")[0] : host; // Block requests without User-Agent (bots that follow redirects infinitely) if (!c.req.header("user-agent")) { throw new HttpError(403, "Forbidden"); } // Source guardian check if (guardian.isDenied(host)) { throw new HttpError(403, "Forbidden"); } // Resolve redirect const redirect = await resolveDnsAndRedirect(host, c.req.url.replace(/^https?:\/\/[^/]+/, "")); // Destination guardian check if (guardian.isDenied(redirect.fqdn)) { throw new HttpError(403, "Forbidden"); } // Self-redirect loop detection: destination points back to the same host if (redirect.fqdn === host) { throw new HttpError(508, `Loop detected: ${host} redirects to itself`); } // Encode non-ASCII characters to avoid ByteString errors in Response headers let safeLocation: string; try { safeLocation = new URL(redirect.url).href; } catch { safeLocation = encodeURI(redirect.url); } // Use " " instead of null to work around Deno.serve memory leak // See: https://github.com/denoland/deno/issues/27545 return new Response(" ", { status: redirect.status, headers: { "Location": safeLocation, "Cache-Control": "public, max-age=15", }, }); } // Periodic health log — helps correlate CPU spikes in CloudWatch with memory/cache state // Memory watchdog — graceful restart when RSS exceeds limit (Deno native memory leak workaround) // See: https://github.com/denoland/deno/issues/28307 const RSS_LIMIT = Number(Deno.env.get("RSS_LIMIT_MB") || "384") * 1024 * 1024; setInterval(() => { const mem = Deno.memoryUsage(); console.log( `[health] rss=${(mem.rss / 1024 / 1024).toFixed(1)}MB heap=${(mem.heapUsed / 1024 / 1024).toFixed(1)}/${(mem.heapTotal / 1024 / 1024).toFixed(1)}MB external=${(mem.external / 1024 / 1024).toFixed(1)}MB dnsCache=${dnsCacheSize()} dnsInflight=${dnsInflightSize()}`, ); if (mem.rss > RSS_LIMIT) { console.warn(`[watchdog] RSS ${(mem.rss / 1024 / 1024).toFixed(0)}MB exceeded limit ${(RSS_LIMIT / 1024 / 1024).toFixed(0)}MB, restarting...`); Deno.exit(0); } }, 60_000); // Start server Deno.serve( { port: config.listenPort, hostname: config.listenIp, onListen({ hostname, port }) { console.log(`[server] Server is listening on ${hostname}:${port}`); }, onError(error) { console.error(`[server] ${error}`); return new Response("Internal Server Error", { status: 500 }); }, }, app.fetch, ); ================================================ FILE: src/middleware/error-handler.ts ================================================ import type { ErrorHandler } from "hono"; import { HttpError } from "../services/redirect.ts"; import { logger } from "../helpers/logger.ts"; export const errorHandler: ErrorHandler = (err, c) => { const status = err instanceof HttpError ? err.status : 500; const message = err.message || "Internal Server Error"; if (err instanceof HttpError) { // Known/expected errors — log without stack trace if (status >= 500) { logger.warn(`[error] ${status} ${message}`); } } else { // Unexpected errors — log full stack for investigation logger.error( `[error] investigate this error: ${err.name}/${err.message}`, err.stack, ); } return c.json({ statusCode: status, message }, status as 400); }; ================================================ FILE: src/services/guardian.ts ================================================ import psl from "psl"; import { logger } from "../helpers/logger.ts"; interface GuardianData { denyFqdn: string[]; } class GuardianService { private filepath: string; private denySet = new Set(); constructor() { this.filepath = new URL("../../db/guardian.json", import.meta.url).pathname; this.openAndParse(); const interval = 60 * 1000; setInterval(() => { logger.debug(`[guardian] db.reload - interval ${interval}`); this.openAndParse(); }, interval); } isDenied(fqdn: string): boolean { // O(1) check against FQDN if (this.denySet.has(fqdn)) return true; // Extract base domain with simple split (covers most cases: example.com from sub.example.com) const parts = fqdn.split("."); if (parts.length > 2) { const baseDomain = parts.slice(-2).join("."); if (this.denySet.has(baseDomain)) return true; } return false; } openAndParse(): void { try { const text = Deno.readTextFileSync(this.filepath); const data: GuardianData = JSON.parse(text || "{}"); // Pre-compute: add both raw entries and their psl-parsed base domains const newSet = new Set(); for (const fqdn of data.denyFqdn ?? []) { newSet.add(fqdn); const parsed = psl.parse(fqdn); if ("domain" in parsed && parsed.domain) { newSet.add(parsed.domain); } } this.denySet = newSet; } catch (err) { logger.error(`[guardian] Failed to load guardian.json: ${err}`); } } } export const guardian = new GuardianService(); ================================================ FILE: src/services/redirect.ts ================================================ import { config } from "../config.ts"; import { createDestination } from "../types/destination.ts"; import type { Destination } from "../types/destination.ts"; import { RedirectResponse } from "../types/redirect-response.ts"; import { dnsResolveCname } from "../helpers/dns.ts"; import { decode } from "../helpers/base32.ts"; import { logger } from "../helpers/logger.ts"; // Reusable TextDecoder — avoids creating native objects per request const textDecoder = new TextDecoder(); export class HttpError extends Error { constructor(public status: number, message: string) { super(message); this.name = "HttpError"; } } export async function resolveDnsAndRedirect( host: string, reqUrl: string, ): Promise { const raw = await resolveDns(host); return getRedirectResponse(raw, reqUrl); } export function getRedirectResponse( raw: string, reqUrl: string, ): RedirectResponse { const destination = parseDestination(raw, reqUrl); return new RedirectResponse(destination); } export async function resolveDns(host: string): Promise { // Extract subdomains via simple split (avoids heavy parseDomain trie lookup) const labels = host.split("."); const hasRedirectSubdomain = labels.includes("redirect"); try { const resolved = await dnsResolveCname(host); if (resolved.length > 1) { throw new HttpError(400, `More than one record on the host ${host}`); } // Remove trailing dot from CNAME if present return resolved[0].replace(/\.$/, ""); } catch (err: unknown) { const error = err as { code?: string; name?: string; status?: number; message?: string }; // Deno's DNS resolver may throw errors without a `code` property // (e.g., "no records found for Query ..."). Detect and normalize them. const isDnsNotFound = error.code === "ENODATA" || (!error.code && error.message?.includes("no records found")); if ( isDnsNotFound && !hasRedirectSubdomain ) { return resolveDns(`redirect.${host}`); } const isKnownDnsError = isDnsNotFound || ["ENOTFOUND", "ESERVFAIL", "EBADRESP", "ECONNREFUSED"].includes( error.code ?? "", ) || error.message?.includes("invalid characters") || error.message?.includes("proto error"); if (isKnownDnsError) { throw new HttpError( 400, `The destination is not properly set, check the host ${host}`, ); } throw err; } } export function parseDestination(raw: string, reqUrl: string): Destination { const destination = createDestination(); let parsedUrl: URL; try { parsedUrl = new URL(reqUrl, "http://placeholder"); } catch { throw new HttpError(400, "Bad Request"); } // Remove trailing dot and FQDN suffix raw = raw.replace(/\.$/, ""); raw = raw.replace(`.${config.fqdn}`, ""); let r: RegExpMatchArray | null; let labels = raw.split("."); labels = labels.map((label) => { switch (true) { case !!label.match(/^(opts-|_)https$/): { destination.protocol = "https"; return ""; } case !!(r = label.match(/^(?:opts-|_)(?:path)-(.*)$/)): { r![1] = r![1].replace(/-/g, "="); destination.pathnames.push( textDecoder.decode(decode(r![1])), ); return ""; } case !!(r = label.match(/^(?:opts-|_)statuscode-(301|302|307|308)$/)): { destination.status = parseInt(r![1]); return ""; } case !!(r = label.match(/^(?:opts-|_)port-(\d+)$/)): { destination.port = parseInt(r![1]); return ""; } case !!label.match(/^(opts-|_)uri$/): { if (parsedUrl.search) { destination.queries.push(parsedUrl.search.substring(1)); } if (parsedUrl.pathname && parsedUrl.pathname !== "/") { destination.pathnames.push(parsedUrl.pathname); } return ""; } default: return label; } }); raw = labels.filter((e) => e).join("."); /* opts-query */ { const queries: string[] = []; let loop = 1; while ( (r = raw.match(/\.(?:opts-|_|)(?:query|base32)[.\-]([^.]+)/)) ) { if (loop++ > 5) logger.warn(`[redirect] CHECK RAW (query) ${raw}`); raw = raw.replace(r[0], ""); r[1] = r[1].replace(/-/g, "="); queries.push(textDecoder.decode(decode(r[1]))); } destination.queries = [...queries, ...destination.queries]; } /* opts-slash */ { const pathnames: string[] = []; let loop = 1; while ( (r = raw.match( /(\.(?:opts-|_|)slash\.)(.*?)(?:(?:(?:.opts-slash|.slash|_slash))|$)/, )) || (r = raw.match(/\.(?:opts-|_|)slash/)) ) { if (loop++ > 5) logger.warn(`[redirect] CHECK RAW (slash) ${raw}`); if (r && r[2]) { raw = raw.replace(`${r[1]}${r[2]}`, ""); pathnames.push(`/${r[2]}`); } else { raw = raw.replace(r![0], ""); pathnames.push("/"); } } destination.pathnames = [...pathnames, ...destination.pathnames]; } destination.host = raw; return destination; } ================================================ FILE: src/services/redirect_test.ts ================================================ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { parseDestination } from "./redirect.ts"; // Override config.fqdn for tests import { config } from "../config.ts"; (config as { fqdn: string }).fqdn = "redirect.center"; Deno.test("parseDestination - opts-slash 1", () => { const raw = "www.youtube.com.opts-slash.watch.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: ["/watch"], status: 301, host: "www.youtube.com", queries: [], }); }); Deno.test("parseDestination - opts-slash 2", () => { const raw = "www.youtube.com.opts-slash.watch.opts-slash.abc.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: ["/watch", "/abc"], status: 301, host: "www.youtube.com", queries: [], }); }); Deno.test("parseDestination - opts-slash 3", () => { const raw = "www.youtube.com.opts-slash.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: ["/"], status: 301, host: "www.youtube.com", queries: [], }); }); Deno.test("parseDestination - opts-slash 4", () => { const raw = "www.youtube.com.opts-slash.watch.opts-slash.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: ["/watch", "/"], status: 301, host: "www.youtube.com", queries: [], }); }); Deno.test("parseDestination - slash 1", () => { const raw = "www.youtube.com.slash.watch.slash.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: ["/watch", "/"], status: 301, host: "www.youtube.com", queries: [], }); }); Deno.test("parseDestination - opts-https 1", () => { const raw = "www.youtube.com.opts-https.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "https", pathnames: [], status: 301, host: "www.youtube.com", queries: [], }); }); Deno.test("parseDestination - opts-statuscode 1", () => { const raw = "www.youtube.com.opts-statuscode-302.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: [], status: 302, host: "www.youtube.com", queries: [], }); }); Deno.test("parseDestination - opts-uri 1", () => { const raw = "www.youtube.com.opts-uri.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: ["/any"], status: 301, host: "www.youtube.com", queries: ["any=true"], }); }); Deno.test("parseDestination - opts-query 1", () => { const raw = "www.youtube.com.opts-query-IFXFS===.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: [], status: 301, host: "www.youtube.com", queries: ["AnY"], }); }); Deno.test("parseDestination - opts-query 2", () => { const raw = "www.youtube.com.opts-query-IFXFS---.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: [], status: 301, host: "www.youtube.com", queries: ["AnY"], }); }); Deno.test("parseDestination - opts-port", () => { const raw = "www.youtube.com.opts-port-8080.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "http", pathnames: [], status: 301, host: "www.youtube.com", queries: [], port: 8080, }); }); Deno.test("parseDestination - mix 1", () => { const raw = "127.0.0.1.opts-slash.opts-query.ifqueysdmm.opts-https.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "https", pathnames: ["/"], status: 301, host: "127.0.0.1", queries: ["AaBbCc"], }); }); Deno.test("parseDestination - mix 2", () => { const raw = "127.0.0.1.opts-path-ifqueysdmm.opts-https.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "https", pathnames: ["AaBbCc"], status: 301, host: "127.0.0.1", queries: [], }); }); Deno.test("parseDestination - mix 3", () => { const raw = "www.test.com.opts-slash.xmart.opts-slash.xmart.dll.opts-https.redirect.center"; const response = parseDestination(raw, "/any?any=true"); assertEquals(response, { protocol: "https", pathnames: ["/xmart", "/xmart.dll"], status: 301, host: "www.test.com", queries: [], }); }); Deno.test("parseDestination - mix 4", () => { const raw = "www.google.com.opts-path-f52gk43u.opts-query-mfrggplemvta.opts-https.redirect.center"; const response = parseDestination(raw, "/"); assertEquals(response, { protocol: "https", pathnames: ["/test"], status: 301, host: "www.google.com", queries: ["abc=def"], }); }); Deno.test("parseDestination - mix 5", () => { const raw = "www.google.com.opts-path-f52gk43u.opts-query-mfrggplemvta.opts-https.opts-uri.redirect.center"; const response = parseDestination(raw, "/abc?fxa"); assertEquals(response, { protocol: "https", pathnames: ["/test", "/abc"], status: 301, host: "www.google.com", queries: ["abc=def", "fxa"], }); }); Deno.test("parseDestination - mix 6", () => { const raw = "www.google.com.opts-slash.test.opts-slash.abc.html.redirect.center."; const response = parseDestination(raw, "/"); assertEquals(response, { protocol: "http", pathnames: ["/test", "/abc.html"], status: 301, host: "www.google.com", queries: [], }); }); Deno.test("parseDestination - mix 7", () => { const raw = "www.google.com.opts-slash.test.opts-slash.abc.opts-slash.redirect.center."; const response = parseDestination(raw, "/"); assertEquals(response, { protocol: "http", pathnames: ["/test", "/abc", "/"], status: 301, host: "www.google.com", queries: [], }); }); Deno.test("parseDestination - mix 8", () => { const raw = "127.0.0.1.opts-port-22602.opts-slash.test.redirect.center."; const response = parseDestination(raw, "/"); assertEquals(response, { protocol: "http", pathnames: ["/test"], status: 301, port: 22602, host: "127.0.0.1", queries: [], }); }); ================================================ FILE: src/services/statistic.ts ================================================ import { parseDomain } from "parse-domain"; import { logger } from "../helpers/logger.ts"; interface Statistic { count: number; firstTime?: string; lastTime?: string; } interface StatisticOverview { periodDomains: number; everDomains: number; } class StatisticService { private kv!: Deno.Kv; private ready: Promise; constructor() { this.ready = this.init(); } private async init(): Promise { this.kv = await Deno.openKv(); await this.kv.set(["meta", "started"], new Date().toISOString()); } async ensureReady(): Promise { await this.ready; } async write(host: string): Promise { await this.ensureReady(); logger.debug(`[statistic] write received host ${host}`); const parsedHost = parseDomain(host) as { domain: string; topLevelDomains: string[]; }; const domain = `${parsedHost.domain}.${parsedHost.topLevelDomains.join(".")}`.toLowerCase(); await this.entryDomain(domain); } private async entryDomain(domain: string): Promise { const key = ["domain", domain]; const existing = await this.kv.get(key); const entry: Statistic = existing.value ?? { count: 0, firstTime: new Date().toISOString(), }; entry.count += 1; entry.lastTime = new Date().toISOString(); await this.kv.set(key, entry); logger.debug( `[statistic] entryDomain key ${domain}, entry: ${JSON.stringify(entry)}`, ); } async overview(): Promise { await this.ensureReady(); const dayBefore = new Date(); dayBefore.setDate(dayBefore.getDate() - 1); const dayBeforeISO = dayBefore.toISOString(); let periodDomains = 0; let everDomains = 0; const iter = this.kv.list({ prefix: ["domain"] }); for await (const entry of iter) { everDomains++; if (entry.value.lastTime && entry.value.lastTime >= dayBeforeISO) { periodDomains++; } } return { periodDomains, everDomains }; } } export const statistic = new StatisticService(); ================================================ FILE: src/types/destination.ts ================================================ export interface Destination { protocol: "http" | "https"; host: string; pathnames: string[]; queries: string[]; status: number; port?: number; } export function createDestination(): Destination { return { protocol: "http", host: "", pathnames: [], queries: [], status: 301, }; } ================================================ FILE: src/types/redirect-response.ts ================================================ import { Destination } from "./destination.ts"; export class RedirectResponse { url: string; status: number; fqdn: string; constructor(destination: Destination) { this.fqdn = destination.host; this.status = destination.status; this.url = `${destination.protocol}://${destination.host}`; if (destination.port && destination.port > 0 && destination.port <= 65535) { this.url += `:${destination.port}`; } this.url += "/"; if (destination.pathnames.length) { const path = destination.pathnames.join(""); if (path.startsWith("/")) this.url += path.substring(1); else this.url += path; } if (destination.queries.length >= 1) { this.url += `?${destination.queries.join("&")}`.replace("?#", "#"); } } } ================================================ FILE: views/index.vto ================================================ {{ app.projectName }} - Free DNS-Based Domain Redirect Service

{{ app.projectName }}

Redirect any domain using only a DNS record. No server, no hosting, no code. Redirecione qualquer domínio usando apenas um registro DNS. Sem servidor, sem hospedagem, sem código.

How it works Como funciona

Three steps. No account, no sign-up — completely free and open source. Três passos. Sem cadastro, sem conta — totalmente gratuito e open source.

1

Point your domain Aponte seu domínio

Create an A record pointing to {{ app.entryIp }} Crie um registro A apontando para {{ app.entryIp }}

2

Set the destination Defina o destino

Create a CNAME like dest.com.{{ app.fqdn }} Crie um CNAME como dest.com.{{ app.fqdn }}

3

Done! Pronto!

Visitors get a 301 redirect automatically. Visitantes recebem um redirect 301 automaticamente.

How to use Como usar

Click each example to see the DNS records you need. Clique em cada exemplo para ver os registros DNS necessários.

RedirectRedirecionar my-domain.comwww.my-domain.com

Redirect your bare/root domain to the www version: Redirecione seu domínio raiz para a versão www:

Host: <empty> (or @) Type: A Value: {{ app.entryIp }}
Host: redirect Type: CNAME Value: www.my-domain.com.{{ app.fqdn }}
💡 Add .opts-https to force HTTPS: www.my-domain.com.opts-https.{{ app.fqdn }} Adicione .opts-https para forçar HTTPS: www.my-domain.com.opts-https.{{ app.fqdn }}
RedirectRedirecionar www.my-domain.comwww.other-domain.com

Redirect a subdomain to a completely different domain: Redirecione um subdomínio para um domínio completamente diferente:

Host: www Type: CNAME Value: www.other-domain.com.{{ app.fqdn }}
💡 Only one DNS record needed for subdomain redirects! Apenas um registro DNS necessário para redirecionamento de subdomínios!
Redirect preserving the path: Redirecionar preservando o caminho: /page/page

Keep the original URL path and query string when redirecting: Mantenha o caminho e a query string originais ao redirecionar:

Host: www Type: CNAME Value: www.other-domain.com.opts-uri.{{ app.fqdn }}
💡 .opts-uri passes the full request path and query string to the destination. .opts-uri repassa o caminho completo e a query string para o destino.
Redirect with path & query: Redirecionar com caminho & query: jobs.my-domain.commy-domain.com/careers?ref=dns

Redirect a subdomain to a specific path with query parameters: Redirecione um subdomínio para um caminho específico com parâmetros de consulta:

Host: jobs Type: CNAME Value: my-domain.com.opts-slash.careers.opts-query-onswg5lsnf2a.{{ app.fqdn }}
💡 .opts-slash.careers adds /careers. The query ref=dns is Base32-encoded. Use the CNAME Generator to build these automatically. .opts-slash.careers adiciona /careers. A query ref=dns é codificada em Base32. Use o Gerador de CNAME para gerar automaticamente.

Parameters Reference Referência de Parâmetros

Modifiers you can add to your CNAME record to customize the redirect. Modificadores que você pode adicionar ao seu registro CNAME para personalizar o redirecionamento.

Parameter Parâmetro Description Descrição Example Exemplo
.opts-https Force redirect to HTTPS Forçar redirecionamento para HTTPS example.com.opts-https.{{ app.fqdn }}
.opts-uri Preserve original path and query string Preservar caminho e query string originais example.com.opts-uri.{{ app.fqdn }}
.opts-slash.{path} Append a path to the destination URL Adicionar um caminho à URL de destino example.com.opts-slash.blog.{{ app.fqdn }}
.opts-path-{base32} Base32-encoded path (for special characters) Caminho codificado em Base32 (para caracteres especiais) example.com.opts-path-mfrgg.{{ app.fqdn }}
.opts-query-{base32} Base32-encoded query string Query string codificada em Base32 example.com.opts-query-nfxgg.{{ app.fqdn }}
.opts-statuscode-{code} HTTP status code: 301, 302, 307 or 308 Código de status HTTP: 301, 302, 307 ou 308 example.com.opts-statuscode-302.{{ app.fqdn }}
.opts-port-{port} Redirect to a specific port Redirecionar para uma porta específica example.com.opts-port-8080.{{ app.fqdn }}